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

const { ObjectId } = mongodb

const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDetailsHandler'

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

describe('ProjectDetailsHandler', function () {
  beforeEach(async function (ctx) {
    ctx.user = {
      _id: new ObjectId(),
      email: 'user@example.com',
      features: 'mock-features',
    }
    ctx.collaborator = {
      _id: new ObjectId(),
      email: 'collaborator@example.com',
    }
    ctx.project = {
      _id: new ObjectId(),
      name: 'project',
      description: 'this is a great project',
      something: 'should not exist',
      compiler: 'latexxxxxx',
      owner_ref: ctx.user._id,
      collaberator_refs: [ctx.collaborator._id],
    }
    ctx.ProjectGetter = {
      promises: {
        getProject: sinon.stub().resolves(ctx.project),
        findAllUsersProjects: sinon.stub().resolves({
          owned: [],
          readAndWrite: [],
          readOnly: [],
          tokenReadAndWrite: [],
          tokenReadOnly: [],
        }),
      },
    }
    ctx.ProjectModelUpdateQuery = {
      exec: sinon.stub().resolves(),
    }
    ctx.ProjectModel = {
      updateOne: sinon.stub().returns(ctx.ProjectModelUpdateQuery),
    }
    ctx.UserGetter = {
      promises: {
        getUser: sinon.stub().resolves(ctx.user),
      },
    }
    ctx.TpdsUpdateSender = {
      promises: {
        moveEntity: sinon.stub().resolves(),
      },
    }
    ctx.TokenGenerator = {
      readAndWriteToken: sinon.stub(),
      promises: {
        generateUniqueReadOnlyToken: sinon.stub(),
      },
    }
    ctx.settings = {
      defaultFeatures: 'default-features',
    }

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

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

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

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

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

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

    vi.doMock('@overleaf/settings', () => ({
      default: ctx.settings,
    }))

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

  describe('getDetails', function () {
    it('should find the project and owner', async function (ctx) {
      const details = await ctx.handler.promises.getDetails(ctx.project._id)
      details.name.should.equal(ctx.project.name)
      details.description.should.equal(ctx.project.description)
      details.compiler.should.equal(ctx.project.compiler)
      details.features.should.equal(ctx.user.features)
      expect(details.something).to.be.undefined
    })

    it('should find overleaf metadata if it exists', async function (ctx) {
      ctx.project.overleaf = { id: 'id' }
      const details = await ctx.handler.promises.getDetails(ctx.project._id)
      details.overleaf.should.equal(ctx.project.overleaf)
      expect(details.something).to.be.undefined
    })

    it('should return an error for a non-existent project', async function (ctx) {
      ctx.ProjectGetter.promises.getProject.resolves(null)
      await expect(
        ctx.handler.promises.getDetails('0123456789012345678901234')
      ).to.be.rejectedWith(Errors.NotFoundError)
    })

    it('should return the default features if no owner found', async function (ctx) {
      ctx.UserGetter.promises.getUser.resolves(null)
      const details = await ctx.handler.promises.getDetails(ctx.project._id)
      details.features.should.equal(ctx.settings.defaultFeatures)
    })

    it('should rethrow any error', async function (ctx) {
      ctx.ProjectGetter.promises.getProject.rejects(new Error('boom'))
      await expect(ctx.handler.promises.getDetails(ctx.project._id)).to.be
        .rejected
    })
  })

  describe('getProjectDescription', function () {
    it('should make a call to mongo just for the description', async function (ctx) {
      ctx.ProjectGetter.promises.getProject.resolves()
      await ctx.handler.promises.getProjectDescription(ctx.project._id)
      expect(ctx.ProjectGetter.promises.getProject).to.have.been.calledWith(
        ctx.project._id,
        { description: true }
      )
    })

    it('should return what the mongo call returns', async function (ctx) {
      const expectedDescription = 'cool project'
      ctx.ProjectGetter.promises.getProject.resolves({
        description: expectedDescription,
      })
      const description = await ctx.handler.promises.getProjectDescription(
        ctx.project._id
      )
      expect(description).to.equal(expectedDescription)
    })
  })

  describe('setProjectDescription', function () {
    beforeEach(function (ctx) {
      ctx.description = 'updated teh description'
    })

    it('should update the project detials', async function (ctx) {
      await ctx.handler.promises.setProjectDescription(
        ctx.project._id,
        ctx.description
      )
      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
        { _id: ctx.project._id },
        { description: ctx.description }
      )
    })
  })

  describe('renameProject', function () {
    beforeEach(function (ctx) {
      ctx.newName = 'new name here'
    })

    it('should update the project with the new name', async function (ctx) {
      await ctx.handler.promises.renameProject(ctx.project._id, ctx.newName)
      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
        { _id: ctx.project._id },
        { name: ctx.newName }
      )
    })

    it('should tell the TpdsUpdateSender', async function (ctx) {
      await ctx.handler.promises.renameProject(ctx.project._id, ctx.newName)
      expect(ctx.TpdsUpdateSender.promises.moveEntity).to.have.been.calledWith({
        projectId: ctx.project._id,
        projectName: ctx.project.name,
        newProjectName: ctx.newName,
      })
    })

    it('should not do anything with an invalid name', async function (ctx) {
      await expect(ctx.handler.promises.renameProject(ctx.project._id)).to.be
        .rejected
      expect(ctx.TpdsUpdateSender.promises.moveEntity).not.to.have.been.called
      expect(ctx.ProjectModel.updateOne).not.to.have.been.called
    })

    it('should trim whitespace around name', async function (ctx) {
      await ctx.handler.promises.renameProject(
        ctx.project._id,
        `   ${ctx.newName}   `
      )
      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
        { _id: ctx.project._id },
        { name: ctx.newName }
      )
    })
  })

  describe('validateProjectName', function () {
    it('should reject undefined names', async function (ctx) {
      await expect(ctx.handler.promises.validateProjectName(undefined)).to.be
        .rejected
    })

    it('should reject empty names', async function (ctx) {
      await expect(ctx.handler.promises.validateProjectName('')).to.be.rejected
    })

    it('should reject names with /s', async function (ctx) {
      await expect(ctx.handler.promises.validateProjectName('foo/bar')).to.be
        .rejected
    })

    it('should reject names with \\s', async function (ctx) {
      await expect(ctx.handler.promises.validateProjectName('foo\\bar')).to.be
        .rejected
    })

    it('should reject long names', async function (ctx) {
      await expect(ctx.handler.promises.validateProjectName('a'.repeat(1000)))
        .to.be.rejected
    })

    it('should accept normal names', async function (ctx) {
      await expect(ctx.handler.promises.validateProjectName('foobar')).to.be
        .fulfilled
    })
  })

  describe('generateUniqueName', function () {
    // actually testing `ProjectHelper.promises.ensureNameIsUnique()`
    beforeEach(function (ctx) {
      ctx.longName = 'x'.repeat(ctx.handler.MAX_PROJECT_NAME_LENGTH - 5)
      const usersProjects = {
        owned: [
          { _id: 1, name: 'name' },
          { _id: 2, name: 'name1' },
          { _id: 3, name: 'name11' },
          { _id: 100, name: 'numeric' },
          { _id: 101, name: 'numeric (1)' },
          { _id: 102, name: 'numeric (2)' },
          { _id: 103, name: 'numeric (3)' },
          { _id: 104, name: 'numeric (4)' },
          { _id: 105, name: 'numeric (5)' },
          { _id: 106, name: 'numeric (6)' },
          { _id: 107, name: 'numeric (7)' },
          { _id: 108, name: 'numeric (8)' },
          { _id: 109, name: 'numeric (9)' },
          { _id: 110, name: 'numeric (10)' },
          { _id: 111, name: 'numeric (11)' },
          { _id: 112, name: 'numeric (12)' },
          { _id: 113, name: 'numeric (13)' },
          { _id: 114, name: 'numeric (14)' },
          { _id: 115, name: 'numeric (15)' },
          { _id: 116, name: 'numeric (16)' },
          { _id: 117, name: 'numeric (17)' },
          { _id: 118, name: 'numeric (18)' },
          { _id: 119, name: 'numeric (19)' },
          { _id: 120, name: 'numeric (20)' },
          { _id: 130, name: 'numeric (30)' },
          { _id: 131, name: 'numeric (31)' },
          { _id: 132, name: 'numeric (32)' },
          { _id: 133, name: 'numeric (33)' },
          { _id: 134, name: 'numeric (34)' },
          { _id: 135, name: 'numeric (35)' },
          { _id: 136, name: 'numeric (36)' },
          { _id: 137, name: 'numeric (37)' },
          { _id: 138, name: 'numeric (38)' },
          { _id: 139, name: 'numeric (39)' },
          { _id: 140, name: 'numeric (40)' },
          { _id: 141, name: 'Yearbook (2021)' },
          { _id: 142, name: 'Yearbook (2021) (1)' },
          { _id: 143, name: 'Resume (2020' },
        ],
        readAndWrite: [
          { _id: 4, name: 'name2' },
          { _id: 5, name: 'name22' },
        ],
        readOnly: [
          { _id: 6, name: 'name3' },
          { _id: 7, name: 'name33' },
        ],
        tokenReadAndWrite: [
          { _id: 8, name: 'name4' },
          { _id: 9, name: 'name44' },
        ],
        tokenReadOnly: [
          { _id: 10, name: 'name5' },
          { _id: 11, name: 'name55' },
          { _id: 12, name: ctx.longName },
        ],
      }
      ctx.ProjectGetter.promises.findAllUsersProjects.resolves(usersProjects)
    })

    it('should leave a unique name unchanged', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        'unique-name',
        ['-test-suffix']
      )
      expect(name).to.equal('unique-name')
    })

    it('should append a suffix to an existing name', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        'name1',
        ['-test-suffix']
      )
      expect(name).to.equal('name1-test-suffix')
    })

    it('should fallback to a second suffix when needed', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        'name1',
        ['1', '-test-suffix']
      )
      expect(name).to.equal('name1-test-suffix')
    })

    it('should truncate the name when append a suffix if the result is too long', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        ctx.longName,
        ['-test-suffix']
      )
      expect(name).to.equal(
        ctx.longName.substr(0, ctx.handler.MAX_PROJECT_NAME_LENGTH - 12) +
          '-test-suffix'
      )
    })

    it('should use a numeric index if no suffix is supplied', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        'name1',
        []
      )
      expect(name).to.equal('name1 (1)')
    })

    it('should use a numeric index if all suffixes are exhausted', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        'name',
        ['1', '11']
      )
      expect(name).to.equal('name (1)')
    })

    it('should find the next lowest available numeric index for the base name', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        'numeric',
        []
      )
      expect(name).to.equal('numeric (21)')
    })

    it('should not find a numeric index lower than the one already present', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        'numeric (31)',
        []
      )
      expect(name).to.equal('numeric (41)')
    })

    it('should handle years in name', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        'unique-name (2021)',
        []
      )
      expect(name).to.equal('unique-name (2021)')
    })

    it('should handle duplicating with year in name', async function (ctx) {
      const name = await ctx.handler.promises.generateUniqueName(
        ctx.user._id,
        'Yearbook (2021)',
        []
      )
      expect(name).to.equal('Yearbook (2021) (2)')
    })
    describe('title with that causes invalid regex', function () {
      it('should create the project with a suffix when project name exists', async function (ctx) {
        const name = await ctx.handler.promises.generateUniqueName(
          ctx.user._id,
          'Resume (2020',
          []
        )
        expect(name).to.equal('Resume (2020 (1)')
      })
      it('should create the project with the provided name', async function (ctx) {
        const name = await ctx.handler.promises.generateUniqueName(
          ctx.user._id,
          'Yearbook (2021',
          []
        )
        expect(name).to.equal('Yearbook (2021')
      })
    })

    describe('numeric index is already present', function () {
      describe('when there is 1 project "x (2)"', function () {
        beforeEach(function (ctx) {
          const usersProjects = {
            owned: [{ _id: 1, name: 'x (2)' }],
          }
          ctx.ProjectGetter.promises.findAllUsersProjects.resolves(
            usersProjects
          )
        })

        it('should produce "x (3)" uploading a zip with name "x (2)"', async function (ctx) {
          const name = await ctx.handler.promises.generateUniqueName(
            ctx.user._id,
            'x (2)',
            []
          )
          expect(name).to.equal('x (3)')
        })
      })

      describe('when there are 2 projects "x (2)" and "x (3)"', function () {
        beforeEach(function (ctx) {
          const usersProjects = {
            owned: [
              { _id: 1, name: 'x (2)' },
              { _id: 2, name: 'x (3)' },
            ],
          }
          ctx.ProjectGetter.promises.findAllUsersProjects.resolves(
            usersProjects
          )
        })

        it('should produce "x (4)" when uploading a zip with name "x (2)"', async function (ctx) {
          const name = await ctx.handler.promises.generateUniqueName(
            ctx.user._id,
            'x (2)',
            []
          )
          expect(name).to.equal('x (4)')
        })
      })

      describe('when there are 2 projects "x (2)" and "x (4)"', function () {
        beforeEach(function (ctx) {
          const usersProjects = {
            owned: [
              { _id: 1, name: 'x (2)' },
              { _id: 2, name: 'x (4)' },
            ],
          }
          ctx.ProjectGetter.promises.findAllUsersProjects.resolves(
            usersProjects
          )
        })

        it('should produce "x (3)" when uploading a zip with name "x (2)"', async function (ctx) {
          const name = await ctx.handler.promises.generateUniqueName(
            ctx.user._id,
            'x (2)',
            []
          )
          expect(name).to.equal('x (3)')
        })

        it('should produce "x (5)" when uploading a zip with name "x (4)"', async function (ctx) {
          const name = await ctx.handler.promises.generateUniqueName(
            ctx.user._id,
            'x (4)',
            []
          )
          expect(name).to.equal('x (5)')
        })
      })
    })
  })

  describe('fixProjectName', function () {
    it('should change empty names to Untitled', function (ctx) {
      expect(ctx.handler.fixProjectName('')).to.equal('Untitled')
    })

    it('should replace / with -', function (ctx) {
      expect(ctx.handler.fixProjectName('foo/bar')).to.equal('foo-bar')
    })

    it("should replace \\ with ''", function (ctx) {
      expect(ctx.handler.fixProjectName('foo \\ bar')).to.equal('foo  bar')
    })

    it('should truncate long names', function (ctx) {
      expect(ctx.handler.fixProjectName('a'.repeat(1000))).to.equal(
        'a'.repeat(150)
      )
    })

    it('should accept normal names', function (ctx) {
      expect(ctx.handler.fixProjectName('foobar')).to.equal('foobar')
    })

    it('should trim name after truncation', function (ctx) {
      expect(ctx.handler.fixProjectName('a'.repeat(149) + ' a')).to.equal(
        'a'.repeat(149)
      )
    })
  })

  describe('setPublicAccessLevel', function () {
    beforeEach(function (ctx) {
      ctx.accessLevel = 'tokenBased'
    })

    it('should update the project with the new level', async function (ctx) {
      await ctx.handler.promises.setPublicAccessLevel(
        ctx.project._id,
        ctx.accessLevel
      )
      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
        { _id: ctx.project._id },
        { publicAccesLevel: ctx.accessLevel }
      )
    })

    it('should not produce an error', async function (ctx) {
      await expect(
        ctx.handler.promises.setPublicAccessLevel(
          ctx.project._id,
          ctx.accessLevel
        )
      ).to.be.fulfilled
    })

    describe('when update produces an error', function () {
      beforeEach(function (ctx) {
        ctx.ProjectModelUpdateQuery.exec.rejects(new Error('woops'))
      })

      it('should produce an error', async function (ctx) {
        await expect(
          ctx.handler.promises.setPublicAccessLevel(
            ctx.project._id,
            ctx.accessLevel
          )
        ).to.be.rejected
      })
    })
  })

  describe('ensureTokensArePresent', function () {
    describe('when the project has tokens', function () {
      beforeEach(function (ctx) {
        ctx.project = {
          _id: ctx.project._id,
          tokens: {
            readOnly: 'aaa',
            readAndWrite: '42bbb',
            readAndWritePrefix: '42',
          },
        }
        ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
      })

      it('should get the project', async function (ctx) {
        await ctx.handler.promises.ensureTokensArePresent(ctx.project._id)
        expect(ctx.ProjectGetter.promises.getProject).to.have.been.calledOnce
        expect(ctx.ProjectGetter.promises.getProject).to.have.been.calledWith(
          ctx.project._id,
          {
            tokens: 1,
          }
        )
      })

      it('should not update the project with new tokens', async function (ctx) {
        await ctx.handler.promises.ensureTokensArePresent(ctx.project._id)
        expect(ctx.ProjectModel.updateOne).not.to.have.been.called
      })
    })

    describe('when tokens are missing', function () {
      beforeEach(function (ctx) {
        ctx.project = { _id: ctx.project._id }
        ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
        ctx.readOnlyToken = 'abc'
        ctx.readAndWriteToken = '42def'
        ctx.readAndWriteTokenPrefix = '42'
        ctx.TokenGenerator.promises.generateUniqueReadOnlyToken.resolves(
          ctx.readOnlyToken
        )
        ctx.TokenGenerator.readAndWriteToken.returns({
          token: ctx.readAndWriteToken,
          numericPrefix: ctx.readAndWriteTokenPrefix,
        })
      })

      it('should get the project', async function (ctx) {
        await ctx.handler.promises.ensureTokensArePresent(ctx.project._id)
        expect(ctx.ProjectGetter.promises.getProject).to.have.been.calledOnce
        expect(ctx.ProjectGetter.promises.getProject).to.have.been.calledWith(
          ctx.project._id,
          {
            tokens: 1,
          }
        )
      })

      it('should update the project with new tokens', async function (ctx) {
        await ctx.handler.promises.ensureTokensArePresent(ctx.project._id)
        expect(ctx.TokenGenerator.promises.generateUniqueReadOnlyToken).to.have
          .been.calledOnce
        expect(ctx.TokenGenerator.readAndWriteToken).to.have.been.calledOnce
        expect(ctx.ProjectModel.updateOne).to.have.been.calledOnce
        expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
          { _id: ctx.project._id },
          {
            $set: {
              tokens: {
                readOnly: ctx.readOnlyToken,
                readAndWrite: ctx.readAndWriteToken,
                readAndWritePrefix: ctx.readAndWriteTokenPrefix,
              },
            },
          }
        )
      })
    })
  })

  describe('clearTokens', function () {
    it('clears the tokens from the project', async function (ctx) {
      await ctx.handler.promises.clearTokens(ctx.project._id)
      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
        { _id: ctx.project._id },
        { $unset: { tokens: 1 }, $set: { publicAccesLevel: 'private' } }
      )
    })
  })
})
