import { vi, expect } from 'vitest'
import mongodb from 'mongodb-legacy'
import sinon from 'sinon'
import Errors from '../../../../app/src/Features/Errors/Errors.js'

const { ObjectId } = mongodb

const modulePath = '../../../../app/src/Features/User/SAMLIdentityManager.mjs'

vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)

describe('SAMLIdentityManager', function () {
  const linkedEmail = 'another@example.com'

  beforeEach(async function (ctx) {
    ctx.userId = '6005c75b12cbcaf771f4a105'
    ctx.user = {
      _id: ctx.userId,
      email: 'not-linked@overleaf.com',
      emails: [{ email: 'not-linked@overleaf.com' }],
      samlIdentifiers: [],
    }
    ctx.auditLog = {
      initiatorId: ctx.userId,
      ipAddress: '0:0:0:0',
    }
    ctx.userAlreadyLinked = {
      _id: '6005c7a012cbcaf771f4a106',
      email: 'linked@overleaf.com',
      emails: [{ email: 'linked@overleaf.com', samlProviderId: '1' }],
      samlIdentifiers: [{ externalUserId: 'linked-id', providerId: '1' }],
    }
    ctx.userEmailExists = {
      _id: '6005c7a012cbcaf771f4a107',
      email: 'exists@overleaf.com',
      emails: [{ email: 'exists@overleaf.com' }],
      samlIdentifiers: [],
    }
    ctx.institution = {
      name: 'Overleaf University',
    }
    ctx.InstitutionsAPI = {
      promises: {
        addEntitlement: sinon.stub().resolves(),
        removeEntitlement: sinon.stub().resolves(),
      },
    }

    ctx.logger = {
      error: sinon.stub(),
    }
    vi.doMock('@overleaf/logger', () => ({
      default: ctx.logger,
    }))

    vi.doMock('mongodb-legacy', () => ({
      default: { ObjectId },
    }))

    vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
      default: (ctx.EmailHandler = {
        sendEmail: sinon.stub().yields(),
      }),
    }))

    vi.doMock(
      '../../../../app/src/Features/Notifications/NotificationsBuilder',
      () => ({
        default: (ctx.NotificationsBuilder = {
          promises: {
            redundantPersonalSubscription: sinon
              .stub()
              .returns({ create: sinon.stub().resolves() }),
          },
        }),
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/SubscriptionLocator',
      () => ({
        default: (ctx.SubscriptionLocator = {
          promises: {
            getUserIndividualSubscription: sinon.stub().resolves(),
          },
        }),
      })
    )

    vi.doMock('../../../../app/src/models/User', () => ({
      User: (ctx.User = {
        findOneAndUpdate: sinon.stub().returns({
          exec: sinon.stub().resolves(ctx.user),
        }),
        findOne: sinon.stub().returns({
          exec: sinon.stub().resolves(),
        }),
        updateOne: sinon.stub().returns({
          exec: sinon.stub().resolves(),
        }),
      }),
    }))

    vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
      default: (ctx.UserAuditLogHandler = {
        promises: {
          addEntry: sinon.stub().resolves(),
        },
      }),
    }))

    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
      default: (ctx.UserGetter = {
        getUser: sinon.stub(),
        promises: {
          getUser: sinon.stub().resolves(ctx.user),
          getUserByAnyEmail: sinon.stub().resolves(),
          getUserFullEmails: sinon.stub().resolves(),
        },
      }),
    }))

    vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
      default: (ctx.UserUpdater = {
        addEmailAddress: sinon.stub(),
        promises: {
          addEmailAddress: sinon.stub().resolves(),
          confirmEmail: sinon.stub().resolves(),
          updateUser: sinon.stub().resolves(),
        },
      }),
    }))

    vi.doMock(
      '../../../../app/src/Features/Institutions/InstitutionsAPI',
      () => ({
        default: ctx.InstitutionsAPI,
      })
    )

    ctx.SAMLIdentityManager = (await import(modulePath)).default
  })

  describe('getUser', function () {
    it('should throw an error if missing all of: provider ID, external user ID, attribute', async function (ctx) {
      let error
      try {
        await ctx.SAMLIdentityManager.getUser(undefined, undefined, undefined)
      } catch (e) {
        error = e
      } finally {
        expect(error).to.exist
        expect(error.message).to.equal('invalid arguments')
        expect(error.info).to.deep.equal({
          providerId: undefined,
          externalUserId: undefined,
          userIdAttribute: undefined,
        })
      }
    })
    it('should throw an error if missing provider ID', async function (ctx) {
      let error
      try {
        await ctx.SAMLIdentityManager.getUser(undefined, 'id123', 'someAttr')
      } catch (e) {
        error = e
      } finally {
        expect(error).to.exist
        expect(error.message).to.equal('invalid arguments')
        expect(error.info).to.deep.equal({
          providerId: undefined,
          externalUserId: 'id123',
          userIdAttribute: 'someAttr',
        })
      }
    })
    it('should throw an error if missing external user ID', async function (ctx) {
      let error
      try {
        await ctx.SAMLIdentityManager.getUser('123', null, 'someAttr')
      } catch (e) {
        error = e
      } finally {
        expect(error).to.exist
        expect(error.message).to.equal('invalid arguments')
        expect(error.info).to.deep.equal({
          providerId: '123',
          externalUserId: null,
          userIdAttribute: 'someAttr',
        })
      }
    })
    it('should throw an error if missing attribute', async function (ctx) {
      let error
      try {
        await ctx.SAMLIdentityManager.getUser('123', 'id123', undefined)
      } catch (e) {
        error = e
      } finally {
        expect(error).to.exist
        expect(error.message).to.equal('invalid arguments')
        expect(error.info).to.deep.equal({
          providerId: '123',
          externalUserId: 'id123',
          userIdAttribute: undefined,
        })
      }
    })
  })

  describe('linkAccounts', function () {
    describe('errors', function () {
      beforeEach(function (ctx) {
        // first call is to get userWithProvider; should be falsy
        ctx.UserGetter.promises.getUser.onFirstCall().resolves()
        ctx.UserGetter.promises.getUser.onSecondCall().resolves(ctx.user)
      })

      it('should throw an error if missing all data', async function (ctx) {
        let error
        try {
          await ctx.SAMLIdentityManager.linkAccounts(null, null, null)
        } catch (e) {
          error = e
        } finally {
          expect(error).to.exist
        }
      })

      describe('linking data', function () {
        const requiredData = {
          externalUserId: 'someUniqueId',
          institutionEmail: 'user@example.com',
          providerId: '123',
          userIdAttribute: 'attribute',
        }
        for (const [data] of Object.entries(requiredData)) {
          const testData = { ...requiredData }
          delete testData[data]
          let error
          it(`should throw an error when missing ${data}`, async function (ctx) {
            try {
              await ctx.SAMLIdentityManager.linkAccounts('123', testData, {})
            } catch (e) {
              error = e
            } finally {
              expect(error).to.exist
              expect(error.message).to.contain(
                'missing data when linking institution SSO'
              )
            }
          })
        }
      })

      describe('when email is already associated with another Overleaf account', function () {
        beforeEach(function (ctx) {
          ctx.UserGetter.promises.getUserByAnyEmail.resolves(
            ctx.userEmailExists
          )
        })

        it('should throw an EmailExistsError error', async function (ctx) {
          let error

          try {
            await ctx.SAMLIdentityManager.linkAccounts(
              '6005c75b12cbcaf771f4a105',
              {
                externalUserId: 'not-linked-id',
                institutionEmail: 'exists@overleaf.com',
                universityId: 'provider-id',
                universityName: 'provider-name',
                hasEntitlement: true,
                userIdAttribute: 'someAttribute',
              },
              {
                intiatorId: '6005c75b12cbcaf771f4a105',
                ip: '0:0:0:0',
              }
            )
          } catch (e) {
            error = e
          } finally {
            expect(error).to.be.instanceof(Errors.EmailExistsError)
            expect(ctx.User.findOneAndUpdate).to.not.have.been.called
          }
        })
      })

      describe('when email is not affiliated', function () {
        beforeEach(function (ctx) {
          ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
          ctx.UserGetter.promises.getUserFullEmails.resolves([
            {
              email: 'not-affiliated@overleaf.com',
            },
          ])
        })

        it('should throw SAMLEmailNotAffiliatedError', async function (ctx) {
          let error
          try {
            await ctx.SAMLIdentityManager.linkAccounts(
              '6005c75b12cbcaf771f4a105',
              {
                externalUserId: 'not-linked-id',
                institutionEmail: 'not-affiliated@overleaf.com',
                universityId: 'provider-id',
                universityName: 'provider-name',
                hasEntitlement: true,
                userIdAttribute: 'someAttribute',
              },
              {
                intiatorId: 'user-id-1',
                ip: '0:0:0:0',
              }
            )
          } catch (e) {
            error = e
          } finally {
            expect(error).to.be.instanceof(Errors.SAMLEmailNotAffiliatedError)
            expect(ctx.User.findOneAndUpdate).to.not.have.been.called
          }
        })
      })

      describe('when email is affiliated with another institution', function () {
        beforeEach(function (ctx) {
          ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
          ctx.UserGetter.promises.getUserFullEmails.resolves([
            {
              email: 'affiliated@overleaf.com',
              affiliation: { institution: { id: '987' } },
            },
          ])
        })

        it('should throw SAMLEmailAffiliatedWithAnotherInstitutionError', async function (ctx) {
          let error
          try {
            await ctx.SAMLIdentityManager.linkAccounts(
              '6005c75b12cbcaf771f4a105',
              {
                externalUserId: 'not-linked-id',
                institutionEmail: 'affiliated@overleaf.com',
                universityId: 'provider-id',
                universityName: 'provider-name',
                hasEntitlement: true,
                userIdAttribute: 'someAttribute',
              },
              {
                intiatorId: 'user-id-1',
                ip: '0:0:0:0',
              }
            )
          } catch (e) {
            error = e
          } finally {
            expect(error).to.be.instanceof(
              Errors.SAMLEmailAffiliatedWithAnotherInstitutionError
            )
            expect(ctx.User.findOneAndUpdate).to.not.have.been.called
          }
        })
      })

      describe('when institution identifier is already associated with another Overleaf account', function () {
        beforeEach(function (ctx) {
          ctx.UserGetter.promises.getUserByAnyEmail.resolves(
            ctx.userAlreadyLinked
          )
        })

        it('should throw an SAMLIdentityExistsError error', async function (ctx) {
          let error
          try {
            await ctx.SAMLIdentityManager.linkAccounts(
              '6005c75b12cbcaf771f4a105',
              {
                externalUserId: 'already-linked-id',
                institutionEmail: 'linked@overleaf.com',
                universityId: 'provider-id',
                universityName: 'provider-name',
                hasEntitlement: true,
                userIdAttribute: 'someAttribute',
              },
              {
                intiatorId: '6005c75b12cbcaf771f4a105',
                ip: '0:0:0:0',
              }
            )
          } catch (e) {
            error = e
          } finally {
            expect(error).to.be.instanceof(Errors.SAMLIdentityExistsError)
            expect(ctx.User.findOneAndUpdate).to.not.have.been.called
          }
        })
      })

      describe('when institution provider is already associated with the user', function () {
        beforeEach(function (ctx) {
          // first call is to get userWithProvider; resolves with any user
          ctx.UserGetter.promises.getUser.onFirstCall().resolves(ctx.user)
        })

        it('should throw an SAMLAlreadyLinkedError error', async function (ctx) {
          let error
          try {
            await ctx.SAMLIdentityManager.linkAccounts(
              '6005c75b12cbcaf771f4a105',
              {
                externalUserId: 'already-linked-id',
                institutionEmail: 'linked@overleaf.com',
                universityId: 123456,
                universityName: 'provider-name',
                hasEntitlement: true,
                userIdAttribute: 'someAttribute',
              },
              {
                intiatorId: '6005c75b12cbcaf771f4a105',
                ip: '0:0:0:0',
              }
            )
          } catch (e) {
            error = e
          } finally {
            expect(
              ctx.UserGetter.promises.getUser
            ).to.have.been.calledWithMatch({
              _id: new ObjectId('6005c75b12cbcaf771f4a105'),
              'samlIdentifiers.providerId': '123456',
            })
            expect(error).to.be.instanceof(Errors.SAMLAlreadyLinkedError)
            expect(ctx.User.findOneAndUpdate).to.not.have.been.called
          }
        })
      })

      it('should pass back errors via UserAuditLogHandler', async function (ctx) {
        let error
        const anError = new Error('oops')
        ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
        try {
          await ctx.SAMLIdentityManager.linkAccounts(
            ctx.user._id,
            {
              externalUserId: 'externalUserId',
              institutionEmail: ctx.user.email,
              universityId: '1',
              universityName: 'Overleaf University',
              hasEntitlement: false,
              userIdAttribute: 'someAttribute',
            },
            {
              intiatorId: '6005c75b12cbcaf771f4a105',
              ipAddress: '0:0:0:0',
            }
          )
        } catch (e) {
          error = e
        } finally {
          expect(error).to.exist
          expect(error).to.equal(anError)
          expect(ctx.EmailHandler.sendEmail).to.not.have.been.called
          expect(ctx.User.updateOne).to.not.have.been.called
        }
      })
    })

    describe('success', function () {
      beforeEach(function (ctx) {
        // first call is to get userWithProvider; should be falsy
        ctx.UserGetter.promises.getUser.onFirstCall().resolves()
        ctx.UserGetter.promises.getUser.onSecondCall().resolves(ctx.user)
      })

      it('should update the user audit log', async function (ctx) {
        const auditLog = {
          initiatorId: '6005c75b12cbcaf771f4a105',
          ipAddress: '0:0:0:0',
        }
        await ctx.SAMLIdentityManager.linkAccounts(
          ctx.user._id,
          {
            externalUserId: 'externalUserId',
            institutionEmail: ctx.user.email,
            universityId: '1',
            universityName: 'Overleaf University',
            hasEntitlement: false,
            userIdAttribute: 'uniqueId',
          },
          auditLog
        )

        expect(
          ctx.UserAuditLogHandler.promises.addEntry
        ).to.have.been.calledWith(
          ctx.user._id,
          'link-institution-sso',
          auditLog.initiatorId,
          auditLog.ipAddress,
          {
            institutionEmail: ctx.user.email,
            providerId: '1',
            providerName: 'Overleaf University',
            userIdAttribute: 'uniqueId',
            externalUserId: 'externalUserId',
          }
        )
      })

      it('should send an email notification', async function (ctx) {
        await ctx.SAMLIdentityManager.linkAccounts(
          ctx.user._id,
          {
            externalUserId: 'externalUserId',
            institutionEmail: ctx.user.email,
            universityId: '1',
            universityName: 'Overleaf University',
            hasEntitlement: false,
            userIdAttribute: 'someAttribute',
          },
          {
            intiatorId: '6005c75b12cbcaf771f4a105',
            ipAddress: '0:0:0:0',
          }
        )

        expect(ctx.User.findOneAndUpdate).to.have.been.called
        expect(ctx.EmailHandler.sendEmail).to.have.been.calledOnce
        const emailArgs = ctx.EmailHandler.sendEmail.lastCall.args
        expect(emailArgs[0]).to.equal('securityAlert')
        expect(emailArgs[1].to).to.equal(ctx.user.email)
        expect(emailArgs[1].actionDescribed).to.contain('was linked')
        expect(emailArgs[1].message[0]).to.contain('Linked')
        expect(emailArgs[1].message[0]).to.contain(ctx.user.email)
      })
    })
  })

  describe('unlinkAccounts', function () {
    it('should update the audit log', async function (ctx) {
      await ctx.SAMLIdentityManager.unlinkAccounts(
        ctx.user._id,
        linkedEmail,
        ctx.user.email,
        '1',
        'Overleaf University',
        ctx.auditLog
      )
      expect(
        ctx.UserAuditLogHandler.promises.addEntry
      ).to.have.been.calledOnce.and.calledWithMatch(
        ctx.user._id,
        'unlink-institution-sso',
        ctx.auditLog.initiatorId,
        ctx.auditLog.ipAddress,
        {
          institutionEmail: linkedEmail,
          providerId: '1',
          providerName: 'Overleaf University',
        }
      )
    })
    it('should remove the identifier with a single update', async function (ctx) {
      await ctx.SAMLIdentityManager.unlinkAccounts(
        ctx.user._id,
        linkedEmail,
        ctx.user.email,
        '1',
        'Overleaf University',
        ctx.auditLog
      )

      expect(ctx.User.updateOne).to.have.been.calledOnce
      expect(ctx.User.updateOne.getCall(0)).to.have.been.calledWithMatch(
        { _id: ctx.user._id },
        {
          $pull: {
            samlIdentifiers: { providerId: '1' },
          },
          $unset: {
            'emails.$[email].samlProviderId': 1,
          },
        },
        {
          arrayFilters: [{ 'email.samlProviderId': '1' }],
        }
      )
    })
    it('should send an email notification', async function (ctx) {
      await ctx.SAMLIdentityManager.unlinkAccounts(
        ctx.user._id,
        linkedEmail,
        ctx.user.email,
        '1',
        'Overleaf University',
        ctx.auditLog
      )
      expect(ctx.User.updateOne).to.have.been.called
      expect(ctx.EmailHandler.sendEmail).to.have.been.calledOnce
      const emailArgs = ctx.EmailHandler.sendEmail.lastCall.args
      expect(emailArgs[0]).to.equal('securityAlert')
      expect(emailArgs[1].to).to.equal(ctx.user.email)
      expect(emailArgs[1].actionDescribed).to.contain('was unlinked')
      expect(emailArgs[1].message[0]).to.contain('No longer linked')
      expect(emailArgs[1].message[0]).to.contain(linkedEmail)
    })

    describe('errors', function () {
      it('should pass back errors via UserAuditLogHandler', async function (ctx) {
        let error
        const anError = new Error('oops')
        ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
        try {
          await ctx.SAMLIdentityManager.unlinkAccounts(
            ctx.user._id,
            linkedEmail,
            ctx.user.email,
            '1',
            'Overleaf University',
            ctx.auditLog
          )
        } catch (e) {
          error = e
        } finally {
          expect(error).to.exist
          expect(error).to.equal(anError)
          expect(ctx.EmailHandler.sendEmail).to.not.have.been.called
          expect(ctx.User.updateOne).to.not.have.been.called
        }
      })
    })
  })

  describe('entitlementAttributeMatches', function () {
    it('should return true when entitlement matches on string', function (ctx) {
      ctx.SAMLIdentityManager.entitlementAttributeMatches(
        'foo bar',
        'bar'
      ).should.equal(true)
    })

    it('should return false when entitlement does not match on string', function (ctx) {
      ctx.SAMLIdentityManager.entitlementAttributeMatches(
        'foo bar',
        'bam'
      ).should.equal(false)
    })

    it('should return false on an invalid matcher', function (ctx) {
      ctx.SAMLIdentityManager.entitlementAttributeMatches(
        'foo bar',
        '('
      ).should.equal(false)
    })

    it('should log error on an invalid matcher', function (ctx) {
      ctx.SAMLIdentityManager.entitlementAttributeMatches('foo bar', '(')
      ctx.logger.error.firstCall.args[0].err.message.should.equal(
        'Invalid regular expression: /(/: Unterminated group'
      )
    })

    it('should return true when entitlement matches on array', function (ctx) {
      ctx.SAMLIdentityManager.entitlementAttributeMatches(
        ['foo', 'bar'],
        'bar'
      ).should.equal(true)
    })

    it('should return false when entitlement does not match array', function (ctx) {
      ctx.SAMLIdentityManager.entitlementAttributeMatches(
        ['foo', 'bar'],
        'bam'
      ).should.equal(false)
    })
  })

  describe('migrateIdentifier', function () {
    const userId = '5efb8b6e9b647b0027e4c0b0'
    const externalUserId = '987zyx'
    const providerId = 123
    const hasEntitlement = false
    const institutionEmail = 'someone@email.com'
    const providerName = 'Example University'
    const auditLog = {
      initiatorId: userId,
      ipAddress: '0.0.0.0',
      migration: {
        from: 'uniqueId',
        to: 'newUniqueId',
      },
    }
    const userIdAttribute = 'newUniqueId'

    it('should remove the old identifier and add the new identifier', async function (ctx) {
      ctx.UserGetter.promises.getUser.resolves()
      ctx.UserGetter.promises.getUserByAnyEmail
        .withArgs(institutionEmail)
        .resolves({ _id: userId, emails: [{ email: institutionEmail }] })
      ctx.UserGetter.promises.getUserFullEmails.withArgs(userId).resolves([
        {
          email: institutionEmail,
          affiliation: { institution: { id: providerId } },
        },
      ])
      await ctx.SAMLIdentityManager.migrateIdentifier(
        userId,
        externalUserId,
        providerId,
        hasEntitlement,
        institutionEmail,
        providerName,
        auditLog,
        userIdAttribute
      )

      expect(ctx.User.updateOne).to.have.been.calledOnce
      const query = {
        _id: userId,
        'samlIdentifiers.providerId': providerId.toString(),
      }

      const update = {
        $set: {
          'samlIdentifiers.$.externalUserId': externalUserId,
          'samlIdentifiers.$.userIdAttribute': userIdAttribute,
        },
      }

      expect(ctx.User.updateOne.lastCall.args).to.deep.equal([query, update])
    })
  })

  describe('unlinkNotMigrated', function () {
    const userId = '5efb8b6e9b647b0027e4c0b0'
    const providerId = '123'
    const institutionEmail = 'someone@email.com'
    const providerName = 'Example University'
    const auditLog = {
      ipAddress: 'N/A',
    }

    it('should remove the identifier om samlIdentifiers and samlProviderId on the email', async function (ctx) {
      ctx.User.findOneAndUpdate = sinon.stub().returns({
        exec: sinon.stub().resolves({
          _id: userId,
          emails: [{ email: institutionEmail, samlProviderId: providerId }],
        }),
      })

      await ctx.SAMLIdentityManager.unlinkNotMigrated(
        userId,
        providerId,
        providerName,
        auditLog
      )

      expect(ctx.User.findOneAndUpdate).to.have.been.calledOnce
      const query = {
        _id: userId,
        'emails.samlProviderId': providerId,
      }
      const update = {
        $pull: {
          samlIdentifiers: {
            providerId,
          },
        },
        $unset: {
          'emails.$.samlProviderId': 1,
        },
      }
      expect(ctx.User.findOneAndUpdate.lastCall.args).to.deep.equal([
        query,
        update,
      ])

      expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledOnce
      expect(
        ctx.UserAuditLogHandler.promises.addEntry.lastCall.args
      ).to.deep.equal([
        userId,
        'unlink-institution-sso-not-migrated',
        undefined,
        'N/A',
        { providerId, providerName },
      ])

      expect(ctx.InstitutionsAPI.promises.removeEntitlement).to.have.been
        .calledOnce
      expect(
        ctx.InstitutionsAPI.promises.removeEntitlement.lastCall.args
      ).to.deep.equal([userId, institutionEmail])
    })
  })
})
