const SandboxedModule = require('sandboxed-module')
const path = require('path')
const sinon = require('sinon')
const modulePath = path.join(
  __dirname,
  '../../../../app/src/Features/User/UserUpdater'
)
const tk = require('timekeeper')
const { expect } = require('chai')
const { normalizeQuery } = require('../../../../app/src/Features/Helpers/Mongo')

describe('UserUpdater', function () {
  beforeEach(function () {
    tk.freeze(Date.now())
    this.mongodb = {
      db: {},
      ObjectId(id) {
        return id
      },
    }
    this.UserGetter = {
      getUserEmail: sinon.stub(),
      getUserByAnyEmail: sinon.stub(),
      promises: {
        ensureUniqueEmailAddress: sinon.stub(),
        getUser: sinon.stub(),
      },
    }
    this.addAffiliation = sinon.stub().yields()
    this.removeAffiliation = sinon.stub().callsArgWith(2, null)
    this.refreshFeatures = sinon.stub().yields()
    this.NewsletterManager = {
      promises: {
        changeEmail: sinon.stub(),
      },
    }
    this.RecurlyWrapper = {
      promises: {
        updateAccountEmailAddress: sinon.stub(),
      },
    }
    this.UserUpdater = SandboxedModule.require(modulePath, {
      requires: {
        '../Helpers/Mongo': { normalizeQuery },
        '../../infrastructure/mongodb': this.mongodb,
        '@overleaf/metrics': {
          timeAsyncMethod: sinon.stub(),
        },
        './UserGetter': this.UserGetter,
        '../Institutions/InstitutionsAPI': (this.InstitutionsAPI = {
          addAffiliation: this.addAffiliation,
          removeAffiliation: this.removeAffiliation,
          promises: {
            addAffiliation: sinon.stub(),
          },
        }),
        '../Email/EmailHandler': (this.EmailHandler = {
          promises: {
            sendEmail: sinon.stub(),
          },
        }),
        '../../infrastructure/Features': (this.Features = {
          hasFeature: sinon.stub().returns(false),
        }),
        '../Subscription/FeaturesUpdater': (this.FeaturesUpdater = {
          refreshFeatures: this.refreshFeatures,
          promises: {
            refreshFeatures: sinon.stub().resolves(),
          },
        }),
        '@overleaf/settings': (this.settings = {}),
        request: (this.request = {}),
        '../Newsletter/NewsletterManager': this.NewsletterManager,
        '../Subscription/RecurlyWrapper': this.RecurlyWrapper,
        './UserAuditLogHandler': (this.UserAuditLogHandler = {
          promises: {
            addEntry: sinon.stub().resolves(),
          },
        }),
      },
    })

    this.stubbedUserEmail = 'hello@world.com'
    this.stubbedUser = {
      _id: '3131231',
      name: 'bob',
      email: this.stubbedUserEmail,
      emails: [
        {
          email: this.stubbedUserEmail,
        },
      ],
    }
    this.newEmail = 'bob@bob.com'
    this.callback = sinon.stub()
  })

  afterEach(function () {
    return tk.reset()
  })

  describe('addAffiliationForNewUser', function (done) {
    beforeEach(function () {
      this.UserUpdater.updateUser = sinon
        .stub()
        .callsArgWith(2, null, { n: 1, nModified: 1, ok: 1 })
    })
    it('should not remove affiliationUnchecked flag if v1 returns an error', function (done) {
      this.addAffiliation.yields(true)
      this.UserUpdater.addAffiliationForNewUser(
        this.stubbedUser._id,
        this.newEmail,
        (error, updated) => {
          expect(error).to.exist
          expect(updated).to.be.undefined
          sinon.assert.notCalled(this.UserUpdater.updateUser)
          done()
        }
      )
    })
    it('should remove affiliationUnchecked flag if v1 does not return an error', function (done) {
      this.addAffiliation.yields()
      this.UserUpdater.addAffiliationForNewUser(
        this.stubbedUser._id,
        this.newEmail,
        error => {
          expect(error).not.to.exist
          sinon.assert.calledOnce(this.UserUpdater.updateUser)
          sinon.assert.calledWithMatch(
            this.UserUpdater.updateUser,
            { _id: this.stubbedUser._id, 'emails.email': this.newEmail },
            { $unset: { 'emails.$.affiliationUnchecked': 1 } }
          )
          done()
        }
      )
    })
  })

  describe('changeEmailAddress', function () {
    beforeEach(function () {
      this.auditLog = {
        initiatorId: 'abc123',
        ipAddress: '0:0:0:0',
      }
      this.UserGetter.getUserEmail.callsArgWith(1, null, this.stubbedUser.email)
      this.UserUpdater.addEmailAddress = sinon.stub().callsArgWith(4)
      this.UserUpdater.setDefaultEmailAddress = sinon.stub().yields()
      this.UserUpdater.removeEmailAddress = sinon.stub().callsArgWith(2)
    })

    it('change email', function (done) {
      this.UserUpdater.changeEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        this.auditLog,
        err => {
          expect(err).not.to.exist
          this.UserUpdater.addEmailAddress
            .calledWith(this.stubbedUser._id, this.newEmail, {}, this.auditLog)
            .should.equal(true)
          this.UserUpdater.setDefaultEmailAddress
            .calledWith(
              this.stubbedUser._id,
              this.newEmail,
              true,
              this.auditLog,
              true
            )
            .should.equal(true)
          this.UserUpdater.removeEmailAddress
            .calledWith(this.stubbedUser._id, this.stubbedUser.email)
            .should.equal(true)
          done()
        }
      )
    })

    it('validates email', function (done) {
      this.UserUpdater.changeEmailAddress(
        this.stubbedUser._id,
        'foo',
        this.auditLog,
        err => {
          expect(err).to.exist
          done()
        }
      )
    })

    it('handle error', function (done) {
      this.UserUpdater.removeEmailAddress.callsArgWith(2, new Error('nope'))
      this.UserUpdater.changeEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        this.auditLog,
        err => {
          expect(err).to.exist
          done()
        }
      )
    })
  })

  describe('addEmailAddress', function () {
    beforeEach(function () {
      this.UserGetter.promises.ensureUniqueEmailAddress = sinon
        .stub()
        .resolves()
      this.UserUpdater.promises.updateUser = sinon.stub().resolves()
    })

    it('add email', function (done) {
      this.UserUpdater.addEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        {},
        { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' },
        err => {
          this.UserGetter.promises.ensureUniqueEmailAddress.called.should.equal(
            true
          )
          expect(err).to.not.exist
          const reversedHostname = this.newEmail
            .split('@')[1]
            .split('')
            .reverse()
            .join('')
          this.UserUpdater.promises.updateUser
            .calledWith(this.stubbedUser._id, {
              $push: {
                emails: {
                  email: this.newEmail,
                  createdAt: sinon.match.date,
                  reversedHostname,
                },
              },
            })
            .should.equal(true)
          done()
        }
      )
    })

    it('add affiliation', function (done) {
      const affiliationOptions = {
        university: { id: 1 },
        role: 'Prof',
        department: 'Math',
      }
      this.UserUpdater.addEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        affiliationOptions,
        { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' },
        err => {
          expect(err).not.to.exist
          this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(
            true
          )
          const { args } = this.InstitutionsAPI.promises.addAffiliation.lastCall
          args[0].should.equal(this.stubbedUser._id)
          args[1].should.equal(this.newEmail)
          args[2].should.equal(affiliationOptions)
          done()
        }
      )
    })

    it('handle affiliation error', function (done) {
      this.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope'))
      this.UserUpdater.addEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        {},
        { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' },
        err => {
          expect(err).to.exist
          this.UserUpdater.promises.updateUser.called.should.equal(false)
          done()
        }
      )
    })

    it('validates email', function (done) {
      this.UserUpdater.addEmailAddress(
        this.stubbedUser._id,
        'bar',
        {},
        { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' },
        err => {
          expect(err).to.exist
          done()
        }
      )
    })

    it('updates the audit log', function (done) {
      this.ip = '127:0:0:0'
      this.UserUpdater.addEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        {},
        { initiatorId: this.stubbedUser._id, ipAddress: this.ip },
        error => {
          expect(error).to.not.exist
          this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(
            true
          )
          const { args } = this.UserAuditLogHandler.promises.addEntry.lastCall
          expect(args[0]).to.equal(this.stubbedUser._id)
          expect(args[1]).to.equal('add-email')
          expect(args[2]).to.equal(this.stubbedUser._id)
          expect(args[3]).to.equal(this.ip)
          expect(args[4]).to.deep.equal({
            newSecondaryEmail: this.newEmail,
          })
          done()
        }
      )
    })

    describe('errors', function () {
      describe('via UserAuditLogHandler', function () {
        const anError = new Error('oops')
        beforeEach(function () {
          this.UserAuditLogHandler.promises.addEntry.throws(anError)
        })
        it('should not add email and should return error', function (done) {
          this.UserUpdater.addEmailAddress(
            this.stubbedUser._id,
            this.newEmail,
            {},
            { initiatorId: this.stubbedUser._id, ipAddress: '127:0:0:0' },
            error => {
              expect(error).to.exist
              expect(error).to.equal(anError)
              expect(this.UserUpdater.promises.updateUser).to.not.have.been
                .called
              done()
            }
          )
        })
      })
    })
  })

  describe('removeEmailAddress', function () {
    beforeEach(function () {
      this.UserUpdater.updateUser = sinon
        .stub()
        .callsArgWith(2, null, { nMatched: 1 })
    })

    it('remove email', function (done) {
      this.UserUpdater.removeEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).not.to.exist
          this.UserUpdater.updateUser
            .calledWith(
              { _id: this.stubbedUser._id, email: { $ne: this.newEmail } },
              { $pull: { emails: { email: this.newEmail } } }
            )
            .should.equal(true)
          done()
        }
      )
    })

    it('remove affiliation', function (done) {
      this.UserUpdater.removeEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).not.to.exist
          this.removeAffiliation.calledOnce.should.equal(true)
          const { args } = this.removeAffiliation.lastCall
          args[0].should.equal(this.stubbedUser._id)
          args[1].should.equal(this.newEmail)
          done()
        }
      )
    })

    it('refresh features', function (done) {
      this.UserUpdater.removeEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).not.to.exist
          sinon.assert.calledWith(this.refreshFeatures, this.stubbedUser._id)
          done()
        }
      )
    })

    it('handle error', function (done) {
      this.UserUpdater.updateUser = sinon
        .stub()
        .callsArgWith(2, new Error('nope'))

      this.UserUpdater.removeEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).to.exist
          done()
        }
      )
    })

    it('handle missed update', function (done) {
      this.UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, { n: 0 })

      this.UserUpdater.removeEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).to.exist
          done()
        }
      )
    })

    it('handle affiliation error', function (done) {
      this.removeAffiliation.callsArgWith(2, new Error('nope'))
      this.UserUpdater.removeEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).to.exist
          this.UserUpdater.updateUser.called.should.equal(false)
          done()
        }
      )
    })

    it('validates email', function (done) {
      this.UserUpdater.removeEmailAddress(this.stubbedUser._id, 'baz', err => {
        expect(err).to.exist
        done()
      })
    })
  })

  describe('setDefaultEmailAddress', function () {
    beforeEach(function () {
      this.auditLog = {
        initiatorId: this.stubbedUser,
        ipAddress: '0:0:0:0',
      }
      this.stubbedUser.emails = [
        {
          email: this.newEmail,
          confirmedAt: new Date(),
        },
      ]
      this.UserGetter.promises.getUser.resolves(this.stubbedUser)
      this.NewsletterManager.promises.changeEmail.callsArgWith(2, null)
      this.RecurlyWrapper.promises.updateAccountEmailAddress.resolves()
    })

    it('set default', function (done) {
      this.UserUpdater.promises.updateUser = sinon.stub().resolves({ n: 1 })

      this.UserUpdater.setDefaultEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        false,
        this.auditLog,
        err => {
          expect(err).not.to.exist
          this.UserUpdater.promises.updateUser
            .calledWith(
              { _id: this.stubbedUser._id, 'emails.email': this.newEmail },
              { $set: { email: this.newEmail } }
            )
            .should.equal(true)
          done()
        }
      )
    })

    it('set changed the email in newsletter', function (done) {
      this.UserUpdater.promises.updateUser = sinon.stub().resolves({ n: 1 })

      this.UserUpdater.setDefaultEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        false,
        this.auditLog,
        err => {
          expect(err).not.to.exist
          this.NewsletterManager.promises.changeEmail
            .calledWith(this.stubbedUser, this.newEmail)
            .should.equal(true)
          this.RecurlyWrapper.promises.updateAccountEmailAddress
            .calledWith(this.stubbedUser._id, this.newEmail)
            .should.equal(true)
          done()
        }
      )
    })

    it('handle error', function (done) {
      this.UserUpdater.promises.updateUser = sinon.stub().rejects(Error('nope'))

      this.UserUpdater.setDefaultEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        false,
        this.auditLog,
        err => {
          expect(err).to.exist
          done()
        }
      )
    })

    it('handle missed update', function (done) {
      this.UserUpdater.promises.updateUser = sinon.stub().resolves({ n: 0 })

      this.UserUpdater.setDefaultEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        false,
        this.auditLog,
        err => {
          expect(err).to.exist
          done()
        }
      )
    })

    it('validates email', function (done) {
      this.UserUpdater.setDefaultEmailAddress(
        this.stubbedUser._id,
        '.edu',
        false,
        this.auditLog,
        err => {
          expect(err).to.exist
          done()
        }
      )
    })

    it('updates audit log', function (done) {
      this.UserUpdater.promises.updateUser = sinon.stub().resolves({ n: 1 })

      this.UserUpdater.setDefaultEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        false,
        this.auditLog,
        error => {
          expect(error).to.not.exist
          expect(
            this.UserAuditLogHandler.promises.addEntry
          ).to.have.been.calledWith(
            this.stubbedUser._id,
            'change-primary-email',
            this.auditLog.initiatorId,
            this.auditLog.ipAddress,
            {
              newPrimaryEmail: this.newEmail,
              oldPrimaryEmail: this.stubbedUser.email,
            }
          )
          done()
        }
      )
    })

    it('blocks email update if audit log returns an error', function (done) {
      this.UserUpdater.promises.updateUser = sinon.stub()
      this.UserAuditLogHandler.promises.addEntry.rejects(new Error('oops'))
      this.UserUpdater.setDefaultEmailAddress(
        this.stubbedUser._id,
        this.newEmail,
        false,
        this.auditLog,
        error => {
          expect(error).to.exist
          expect(this.UserUpdater.promises.updateUser).to.not.have.been.called
          done()
        }
      )
    })

    describe('when email not confirmed', function () {
      beforeEach(function () {
        this.stubbedUser.emails = [
          {
            email: this.newEmail,
            confirmedAt: null,
          },
        ]
        this.UserUpdater.promises.updateUser = sinon.stub()
      })

      it('should callback with error', function () {
        this.UserUpdater.setDefaultEmailAddress(
          this.stubbedUser._id,
          this.newEmail,
          false,
          this.auditLog,
          error => {
            expect(error).to.exist
            expect(error.name).to.equal('UnconfirmedEmailError')
            this.UserUpdater.promises.updateUser.callCount.should.equal(0)
            this.NewsletterManager.promises.changeEmail.callCount.should.equal(
              0
            )
          }
        )
      })
    })

    describe('when email does not belong to user', function () {
      beforeEach(function () {
        this.stubbedUser.emails = []
        this.UserGetter.promises.getUser.resolves(this.stubbedUser)
        this.UserUpdater.promises.updateUser = sinon.stub()
      })

      it('should callback with error', function () {
        this.UserUpdater.setDefaultEmailAddress(
          this.stubbedUser._id,
          this.newEmail,
          false,
          this.auditLog,
          error => {
            expect(error).to.exist
            expect(error.name).to.equal('Error')
            this.UserUpdater.promises.updateUser.callCount.should.equal(0)
            this.NewsletterManager.promises.changeEmail.callCount.should.equal(
              0
            )
          }
        )
      })
    })

    describe('security alert', function () {
      it('should be sent to old and new email when sendSecurityAlert=true', function (done) {
        // this.UserGetter.promises.getUser.resolves(this.stubbedUser)
        this.UserUpdater.promises.updateUser = sinon.stub().resolves({ n: 1 })

        this.UserUpdater.setDefaultEmailAddress(
          this.stubbedUser._id,
          this.newEmail,
          false,
          this.auditLog,
          true,
          error => {
            expect(error).to.not.exist
            this.EmailHandler.promises.sendEmail.callCount.should.equal(2)
            const toOldEmailAlert = this.EmailHandler.promises.sendEmail
              .firstCall
            expect(toOldEmailAlert.args[0]).to.equal('securityAlert')
            const toNewEmailAlert = this.EmailHandler.promises.sendEmail
              .lastCall
            expect(toOldEmailAlert.args[1].to).to.equal(this.stubbedUser.email)
            expect(toNewEmailAlert.args[0]).to.equal('securityAlert')
            expect(toNewEmailAlert.args[1].to).to.equal(this.newEmail)
            done()
          }
        )
      })
      describe('errors', function () {
        const anError = new Error('oops')
        describe('EmailHandler', function () {
          beforeEach(function () {
            this.EmailHandler.promises.sendEmail.rejects(anError)
            this.UserUpdater.promises.updateUser = sinon
              .stub()
              .resolves({ n: 1 })
          })
          it('should log but not pass back the error', function (done) {
            this.UserUpdater.setDefaultEmailAddress(
              this.stubbedUser._id,
              this.newEmail,
              false,
              this.auditLog,
              true,
              error => {
                expect(error).to.not.exist
                const loggerCall = this.logger.error.firstCall
                expect(loggerCall.args[0]).to.deep.equal({
                  error: anError,
                  userId: this.stubbedUser._id,
                })
                expect(loggerCall.args[1]).to.contain(
                  'could not send security alert email when primary email changed'
                )
                done()
              }
            )
          })
        })
      })
    })
  })

  describe('confirmEmail', function () {
    beforeEach(function () {
      this.UserUpdater.promises.updateUser = sinon.stub().resolves({ n: 1 })
    })

    it('should update the email record', function (done) {
      this.UserUpdater.confirmEmail(
        this.stubbedUser._id,
        this.stubbedUserEmail,
        err => {
          expect(err).not.to.exist
          this.UserUpdater.promises.updateUser
            .calledWith(
              {
                _id: this.stubbedUser._id,
                'emails.email': this.stubbedUserEmail,
              },
              {
                $set: {
                  'emails.$.reconfirmedAt': new Date(),
                },
                $min: {
                  'emails.$.confirmedAt': new Date(),
                },
              }
            )
            .should.equal(true)
          done()
        }
      )
    })

    it('add affiliation', function (done) {
      this.UserUpdater.confirmEmail(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).not.to.exist
          this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(
            true
          )
          sinon.assert.calledWith(
            this.InstitutionsAPI.promises.addAffiliation,
            this.stubbedUser._id,
            this.newEmail,
            { confirmedAt: new Date() }
          )
          done()
        }
      )
    })

    it('handle error', function (done) {
      this.UserUpdater.promises.updateUser = sinon
        .stub()
        .throws(new Error('nope'))

      this.UserUpdater.confirmEmail(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).to.exist
          done()
        }
      )
    })

    it('handle missed update', function (done) {
      this.UserUpdater.promises.updateUser = sinon.stub().resolves({ n: 0 })

      this.UserUpdater.confirmEmail(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).to.exist
          done()
        }
      )
    })

    it('validates email', function (done) {
      this.UserUpdater.confirmEmail(this.stubbedUser._id, '@', err => {
        expect(err).to.exist
        done()
      })
    })

    it('handle affiliation error', function (done) {
      this.InstitutionsAPI.promises.addAffiliation.throws(Error('nope'))
      this.UserUpdater.confirmEmail(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).to.exist
          this.UserUpdater.promises.updateUser.called.should.equal(false)
          done()
        }
      )
    })

    it('refresh features', function (done) {
      this.UserUpdater.confirmEmail(
        this.stubbedUser._id,
        this.newEmail,
        err => {
          expect(err).not.to.exist
          sinon.assert.calledWith(
            this.FeaturesUpdater.promises.refreshFeatures,
            this.stubbedUser._id
          )
          done()
        }
      )
    })
  })
})
