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

const { ObjectId } = mongodb

const MODULE_PATH =
  '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs'

describe('OwnershipTransferHandler', function () {
  beforeEach(async function (ctx) {
    ctx.user = { _id: new ObjectId(), email: 'owner@example.com' }
    ctx.collaborator = {
      _id: new ObjectId(),
      email: 'collaborator@example.com',
    }
    ctx.readOnlyCollaborator = {
      _id: new ObjectId(),
      email: 'readonly@example.com',
    }
    ctx.reviewer = {
      _id: new ObjectId(),
      email: 'reviewer@example.com',
    }
    ctx.project = {
      _id: new ObjectId(),
      name: 'project',
      owner_ref: ctx.user._id,
      collaberator_refs: [ctx.collaborator._id],
      readOnly_refs: [ctx.readOnlyCollaborator._id],
      reviewer_refs: [ctx.reviewer._id],
    }
    ctx.ProjectGetter = {
      promises: {
        getProject: sinon.stub().resolves(ctx.project),
      },
    }
    ctx.ProjectModel = {
      find: sinon.stub().resolves([]),
      updateOne: sinon.stub().returns({
        exec: sinon.stub().resolves(),
      }),
    }
    ctx.UserGetter = {
      promises: {
        getUser: sinon.stub().resolves(),
      },
    }
    ctx.TpdsUpdateSender = {
      promises: {
        moveEntity: sinon.stub().resolves(),
      },
    }
    ctx.TpdsProjectFlusher = {
      promises: {
        flushProjectToTpds: sinon.stub().resolves(),
      },
    }
    ctx.CollaboratorsHandler = {
      promises: {
        removeUserFromProject: sinon.stub().resolves(),
        addUserIdToProject: sinon.stub().resolves(),
      },
    }
    ctx.EmailHandler = {
      promises: {
        sendEmail: sinon.stub().resolves(),
      },
    }
    ctx.ProjectAuditLogHandler = {
      promises: {
        addEntry: sinon.stub().resolves(),
      },
    }
    ctx.TagsHandler = {
      promises: {
        createTag: sinon.stub().resolves(),
        addProjectsToTag: sinon.stub().resolves(),
      },
    }
    ctx.LimitationsManager = {
      promises: {
        canAddXEditCollaborators: sinon.stub().resolves(true),
      },
    }
    ctx.Modules = {
      promises: {
        hooks: {
          fire: sinon.stub().resolves(),
        },
      },
    }

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

    vi.doMock('../../../../app/src/Features/Project/ProjectGetter.mjs', () => ({
      default: ctx.ProjectGetter,
    }))

    vi.doMock('../../../../app/src/models/Project.mjs', () => ({
      Project: ctx.ProjectModel,
    }))

    vi.doMock('../../../../app/src/Features/Tags/TagsHandler.mjs', () => ({
      default: ctx.TagsHandler,
    }))

    vi.doMock('../../../../app/src/Features/User/UserGetter.mjs', () => ({
      default: ctx.UserGetter,
    }))

    vi.doMock(
      '../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher.mjs',
      () => ({
        default: ctx.TpdsProjectFlusher,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
      () => ({
        default: ctx.ProjectAuditLogHandler,
      })
    )

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

    vi.doMock(
      '../../../../app/src/Features/Collaborators/CollaboratorsHandler.mjs',
      () => ({
        default: ctx.CollaboratorsHandler,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Analytics/AnalyticsManager',
      () => ({
        default: {
          recordEventForUserInBackground: (ctx.recordEventForUserInBackground =
            sinon.stub()),
        },
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/LimitationsManager.mjs',
      () => ({
        default: ctx.LimitationsManager,
      })
    )

    vi.doMock('../../../../app/src/infrastructure/Modules.mjs', () => ({
      default: ctx.Modules,
    }))

    ctx.handler = (await import(MODULE_PATH)).default
  })

  describe('transferOwnership', function () {
    beforeEach(function (ctx) {
      ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
      ctx.UserGetter.promises.getUser
        .withArgs(ctx.collaborator._id)
        .resolves(ctx.collaborator)
      ctx.UserGetter.promises.getUser
        .withArgs(ctx.readOnlyCollaborator._id)
        .resolves(ctx.readOnlyCollaborator)
      ctx.UserGetter.promises.getUser
        .withArgs(ctx.reviewer._id)
        .resolves(ctx.reviewer)
    })

    it("should return a not found error if the project can't be found", async function (ctx) {
      ctx.ProjectGetter.promises.getProject.resolves(null)
      await expect(
        ctx.handler.promises.transferOwnership('abc', ctx.collaborator._id)
      ).to.be.rejectedWith(Errors.ProjectNotFoundError)
    })

    it("should return a not found error if the user can't be found", async function (ctx) {
      ctx.UserGetter.promises.getUser
        .withArgs(ctx.collaborator._id)
        .resolves(null)
      await expect(
        ctx.handler.promises.transferOwnership(
          ctx.project._id,
          ctx.collaborator._id
        )
      ).to.be.rejectedWith(Errors.UserNotFoundError)
    })

    it('should return an error if user cannot be removed as collaborator ', async function (ctx) {
      ctx.CollaboratorsHandler.promises.removeUserFromProject.rejects(
        new Error('user-cannot-be-removed')
      )
      await expect(
        ctx.handler.promises.transferOwnership(
          ctx.project._id,
          ctx.collaborator._id
        )
      ).to.be.rejected
    })

    it('should transfer ownership of the project', async function (ctx) {
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.collaborator._id
      )
      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
        { _id: ctx.project._id },
        sinon.match({ $set: { owner_ref: ctx.collaborator._id } })
      )
    })

    it('should check collaborator limits after ownership transfer', async function (ctx) {
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.collaborator._id
      )

      expect(ctx.ProjectModel.updateOne).to.have.been.calledBefore(
        ctx.LimitationsManager.promises.canAddXEditCollaborators
      )

      expect(
        ctx.LimitationsManager.promises.canAddXEditCollaborators
      ).to.have.been.calledBefore(
        ctx.CollaboratorsHandler.promises.addUserIdToProject
      )
    })

    describe('when there are edit collaborator slots available', function () {
      beforeEach(function (ctx) {
        ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(true)
      })

      it('should give old owner read/write permissions when transferring to a read-only collaborator', async function (ctx) {
        await ctx.handler.promises.transferOwnership(
          ctx.project._id,
          ctx.readOnlyCollaborator._id
        )
        expect(
          ctx.CollaboratorsHandler.promises.addUserIdToProject
        ).to.have.been.calledWith(
          ctx.project._id,
          ctx.readOnlyCollaborator._id,
          ctx.user._id,
          PrivilegeLevels.READ_AND_WRITE,
          undefined
        )
        expect(
          ctx.LimitationsManager.promises.canAddXEditCollaborators
        ).to.have.been.calledWith(ctx.project._id, 1)
      })

      it('should give old owner read/write permissions when transferring to an editor', async function (ctx) {
        await ctx.handler.promises.transferOwnership(
          ctx.project._id,
          ctx.collaborator._id
        )
        expect(
          ctx.CollaboratorsHandler.promises.addUserIdToProject
        ).to.have.been.calledWith(
          ctx.project._id,
          ctx.collaborator._id,
          ctx.user._id,
          PrivilegeLevels.READ_AND_WRITE,
          undefined
        )
        expect(
          ctx.LimitationsManager.promises.canAddXEditCollaborators
        ).to.have.been.calledWith(ctx.project._id, 1)
      })

      it('should give old owner read/write permissions when transferring to a reviewer', async function (ctx) {
        await ctx.handler.promises.transferOwnership(
          ctx.project._id,
          ctx.reviewer._id
        )
        expect(
          ctx.CollaboratorsHandler.promises.addUserIdToProject
        ).to.have.been.calledWith(
          ctx.project._id,
          ctx.reviewer._id,
          ctx.user._id,
          PrivilegeLevels.READ_AND_WRITE,
          undefined
        )
        expect(
          ctx.LimitationsManager.promises.canAddXEditCollaborators
        ).to.have.been.calledWith(ctx.project._id, 1)
      })

      it('should give old owner read/write permissions when transferring to a non-collaborator', async function (ctx) {
        ctx.project.collaberator_refs = []
        ctx.project.readOnly_refs = []
        ctx.project.reviewer_refs = []
        const newOwner = {
          _id: new ObjectId(),
          email: 'admin@example.com',
        }
        ctx.UserGetter.promises.getUser
          .withArgs(newOwner._id)
          .resolves(newOwner)

        await ctx.handler.promises.transferOwnership(
          ctx.project._id,
          newOwner._id,
          { allowTransferToNonCollaborators: true }
        )
        expect(
          ctx.CollaboratorsHandler.promises.addUserIdToProject
        ).to.have.been.calledWith(
          ctx.project._id,
          newOwner._id,
          ctx.user._id,
          PrivilegeLevels.READ_AND_WRITE,
          undefined
        )
        expect(
          ctx.LimitationsManager.promises.canAddXEditCollaborators
        ).to.have.been.calledWith(ctx.project._id, 1)
      })
    })

    describe('when there are no edit collaborator slots available', function () {
      beforeEach(function (ctx) {
        ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(false)
      })

      it('should give old owner read-only with pending editor flag when transferring to an editor', async function (ctx) {
        await ctx.handler.promises.transferOwnership(
          ctx.project._id,
          ctx.collaborator._id
        )
        expect(
          ctx.CollaboratorsHandler.promises.addUserIdToProject
        ).to.have.been.calledWith(
          ctx.project._id,
          ctx.collaborator._id,
          ctx.user._id,
          PrivilegeLevels.READ_ONLY,
          { pendingEditor: true }
        )
      })

      it('should give old owner read-only with pending editor flag when transferring to a reviewer', async function (ctx) {
        await ctx.handler.promises.transferOwnership(
          ctx.project._id,
          ctx.reviewer._id
        )
        expect(
          ctx.CollaboratorsHandler.promises.addUserIdToProject
        ).to.have.been.calledWith(
          ctx.project._id,
          ctx.reviewer._id,
          ctx.user._id,
          PrivilegeLevels.READ_ONLY,
          { pendingEditor: true }
        )
      })

      it('should give old owner read-only with pending editor flag when transferring to a read-only collaborator', async function (ctx) {
        await ctx.handler.promises.transferOwnership(
          ctx.project._id,
          ctx.readOnlyCollaborator._id
        )
        expect(
          ctx.CollaboratorsHandler.promises.addUserIdToProject
        ).to.have.been.calledWith(
          ctx.project._id,
          ctx.readOnlyCollaborator._id,
          ctx.user._id,
          PrivilegeLevels.READ_ONLY,
          { pendingEditor: true }
        )
      })

      it('should give old owner read-only with pending editor flag when transferring to a non-collaborator', async function (ctx) {
        ctx.project.collaberator_refs = []
        ctx.project.readOnly_refs = []
        ctx.project.reviewer_refs = []
        const newOwner = {
          _id: new ObjectId(),
          email: 'newowner@example.com',
        }
        ctx.UserGetter.promises.getUser
          .withArgs(newOwner._id)
          .resolves(newOwner)

        await ctx.handler.promises.transferOwnership(
          ctx.project._id,
          newOwner._id,
          { allowTransferToNonCollaborators: true }
        )
        expect(
          ctx.CollaboratorsHandler.promises.addUserIdToProject
        ).to.have.been.calledWith(
          ctx.project._id,
          newOwner._id,
          ctx.user._id,
          PrivilegeLevels.READ_ONLY,
          { pendingEditor: true }
        )
      })
    })

    it('should do nothing if transferring back to the owner', async function (ctx) {
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.user._id
      )
      expect(ctx.ProjectModel.updateOne).not.to.have.been.called
    })

    it("should remove the user from the project's collaborators", async function (ctx) {
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.collaborator._id
      )
      expect(
        ctx.CollaboratorsHandler.promises.removeUserFromProject
      ).to.have.been.calledWith(ctx.project._id, ctx.collaborator._id)
    })

    it('should transfer ownership of the project to a reviewer', async function (ctx) {
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.reviewer._id
      )
      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
        { _id: ctx.project._id },
        sinon.match({ $set: { owner_ref: ctx.reviewer._id } })
      )
    })

    it('should flush the project to tpds', async function (ctx) {
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.collaborator._id
      )
      expect(
        ctx.TpdsProjectFlusher.promises.flushProjectToTpds
      ).to.have.been.calledWith(ctx.project._id)
    })

    it('should send an email notification', async function (ctx) {
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.collaborator._id
      )
      expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
        'ownershipTransferConfirmationPreviousOwner',
        {
          to: ctx.user.email,
          project: ctx.project,
          newOwner: ctx.collaborator,
        }
      )
      expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
        'ownershipTransferConfirmationNewOwner',
        {
          to: ctx.collaborator.email,
          project: ctx.project,
          previousOwner: ctx.user,
        }
      )
    })

    it('should not send an email notification with the skipEmails option', async function (ctx) {
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.collaborator._id,
        { skipEmails: true }
      )
      expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
    })

    it('should track the change in BigQuery', async function (ctx) {
      const sessionUserId = new ObjectId()
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.collaborator._id,
        { sessionUserId }
      )
      expect(ctx.recordEventForUserInBackground).to.have.been.calledWith(
        ctx.user._id,
        'project-ownership-transfer',
        {
          projectId: ctx.project._id,
          newOwnerId: ctx.collaborator._id,
        }
      )
    })

    it('should write an entry in the audit log', async function (ctx) {
      const sessionUserId = new ObjectId()
      const ipAddress = '1.2.3.4'
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.collaborator._id,
        { sessionUserId, ipAddress }
      )
      expect(
        ctx.ProjectAuditLogHandler.promises.addEntry
      ).to.have.been.calledWith(
        ctx.project._id,
        'transfer-ownership',
        sessionUserId,
        ipAddress,
        {
          previousOwnerId: ctx.user._id,
          newOwnerId: ctx.collaborator._id,
        }
      )
    })

    it('should fire the ownershipTransferred hook', async function (ctx) {
      await ctx.handler.promises.transferOwnership(
        ctx.project._id,
        ctx.collaborator._id
      )
      expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
        'projectOwnershipTransferred',
        ctx.project._id,
        ctx.user._id,
        ctx.collaborator._id
      )
    })

    it('should decline to transfer ownership to a non-collaborator', async function (ctx) {
      ctx.project.collaberator_refs = []
      ctx.project.readOnly_refs = []
      await expect(
        ctx.handler.promises.transferOwnership(
          ctx.project._id,
          ctx.collaborator._id
        )
      ).to.be.rejectedWith(Errors.UserNotCollaboratorError)
    })
  })

  describe('transferAllProjectsToUser', function () {
    const fromUserEmail = 'user.one@example.com'
    const ipAddress = '1.2.3.4'
    let fromUserId, toUserId
    beforeEach(function () {
      fromUserId = new ObjectId().toString()
      toUserId = new ObjectId().toString()
    })

    describe('with missing user', function () {
      it('should throw an error', async function (ctx) {
        ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null)
        ctx.UserGetter.promises.getUser
          .withArgs(toUserId)
          .resolves({ _id: new ObjectId(toUserId) })
        await expect(
          ctx.handler.promises.transferAllProjectsToUser({
            toUserId,
            fromUserId,
            ipAddress,
          })
        ).to.be.rejectedWith(/missing source user/)

        ctx.UserGetter.promises.getUser
          .withArgs(fromUserId)
          .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
        ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves(null)
        await expect(
          ctx.handler.promises.transferAllProjectsToUser({
            fromUserId,
            toUserId,
            ipAddress,
          })
        ).to.be.rejectedWith(/missing destination user/)
      })
    })

    describe('with the same id', function () {
      it('should throw an error', async function (ctx) {
        ctx.UserGetter.promises.getUser
          .withArgs(fromUserId)
          .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
        await expect(
          ctx.handler.promises.transferAllProjectsToUser({
            fromUserId,
            toUserId: fromUserId,
            ipAddress,
          })
        ).to.be.rejectedWith(/rejecting transfer between identical users/)
      })
    })

    describe('happy path', function () {
      let tag, fromUserEmail, projects

      beforeEach(function (ctx) {
        tag = {
          _id: new ObjectId(),
          name: 'some-tag-name',
        }
        projects = [
          { _id: 'project-1' },
          { _id: 'project-2' },
          { _id: 'project-3' },
        ]

        ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves({
          _id: new ObjectId(fromUserId),
          email: fromUserEmail,
        })
        ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves({
          _id: new ObjectId(toUserId),
        })
        ctx.ProjectModel.find.resolves(projects)
        ctx.TagsHandler.promises.createTag.resolves({
          _id: tag._id,
          name: 'some-tag-name',
        })
        ctx.TagsHandler.promises.addProjectsToTag.resolves()
      })

      it('creates a tag', async function (ctx) {
        await ctx.handler.promises.transferAllProjectsToUser({
          fromUserId,
          toUserId,
          ipAddress,
        })
        expect(ctx.TagsHandler.promises.createTag).to.have.been.calledWith(
          toUserId,
          `transferred-from-${fromUserEmail}`,
          '#434AF0',
          { truncate: true }
        )
      })

      it('returns a projectCount, and tag name', async function (ctx) {
        const result = await ctx.handler.promises.transferAllProjectsToUser({
          fromUserId,
          toUserId,
          ipAddress,
        })
        expect(result.projectCount).to.equal(projects.length)
        expect(result.newTagName).to.equal('some-tag-name')
      })

      it('gets the user records', async function (ctx) {
        await ctx.handler.promises.transferAllProjectsToUser({
          fromUserId,
          toUserId,
          ipAddress,
        })
        expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
          fromUserId
        )
        expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
          toUserId
        )
      })

      it('gets the list of affected projects', async function (ctx) {
        await ctx.handler.promises.transferAllProjectsToUser({
          fromUserId,
          toUserId,
          ipAddress,
        })
        expect(ctx.ProjectModel.find).to.have.been.calledWith({
          owner_ref: fromUserId,
        })
      })

      it('transfers all of the projects', async function (ctx) {
        await ctx.handler.promises.transferAllProjectsToUser({
          fromUserId,
          toUserId,
          ipAddress,
        })

        expect(ctx.ProjectModel.updateOne.callCount).to.equal(3)
        expect(ctx.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1)

        for (const project of projects) {
          expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
            { _id: project._id },
            sinon.match({ $set: { owner_ref: toUserId } })
          )
        }
        expect(
          ctx.TagsHandler.promises.addProjectsToTag
        ).to.have.been.calledWith(
          toUserId,
          tag._id,
          projects.map(p => p._id)
        )
      })
    })
  })
})
