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

const { ObjectId } = mongodb

const modulePath = '../../../../app/src/Features/History/HistoryController.mjs'

describe('HistoryController', function () {
  beforeEach(async function (ctx) {
    ctx.callback = sinon.stub()
    ctx.user_id = 'user-id-123'
    ctx.project_id = '000000000000000012345678'
    ctx.blobHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
    ctx.stream = sinon.stub()
    ctx.fetchResponse = {
      headers: {
        get: sinon.stub(),
      },
    }
    ctx.next = sinon.stub()

    ctx.SessionManager = {
      getLoggedInUserId: sinon.stub().returns(ctx.user_id),
    }

    ctx.Stream = {
      pipeline: sinon.stub().resolves(),
    }

    ctx.HistoryManager = {
      promises: {
        injectUserDetails: sinon.stub(),
        requestBlobWithProjectId: sinon.stub(),
      },
    }

    ctx.ProjectEntityUpdateHandler = {
      promises: {
        resyncProjectHistory: sinon.stub().resolves(),
      },
    }

    ctx.fetchJson = sinon.stub()
    ctx.fetchStream = sinon.stub().resolves(ctx.stream)
    ctx.fetchStreamWithResponse = sinon
      .stub()
      .resolves({ stream: ctx.stream, response: ctx.fetchResponse })
    ctx.fetchNothing = sinon.stub().resolves()

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

    vi.doMock('stream/promises', () => ctx.Stream)

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

    vi.doMock('@overleaf/fetch-utils', () => ({
      fetchJson: ctx.fetchJson,
      fetchStream: ctx.fetchStream,
      fetchStreamWithResponse: ctx.fetchStreamWithResponse,
      fetchNothing: ctx.fetchNothing,
    }))

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

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

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

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

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

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

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

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

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

    vi.doMock('../../../../app/src/infrastructure/Features.mjs', () => ({
      default: (ctx.Features = sinon.stub().withArgs('saas').returns(true)),
    }))

    ctx.HistoryController = (await import(modulePath)).default
    ctx.settings.apis = {
      project_history: {
        url: 'http://project_history.example.com',
      },
    }
  })

  describe('proxyToHistoryApi', function () {
    beforeEach(async function (ctx) {
      ctx.req = { url: '/mock/url', method: 'POST', session: sinon.stub() }
      ctx.res = {
        set: sinon.stub(),
      }
      ctx.contentType = 'application/json'
      ctx.contentLength = 212
      ctx.fetchResponse.headers.get
        .withArgs('Content-Type')
        .returns(ctx.contentType)
      ctx.fetchResponse.headers.get
        .withArgs('Content-Length')
        .returns(ctx.contentLength)
      await ctx.HistoryController.proxyToHistoryApi(ctx.req, ctx.res, ctx.next)
    })

    it('should get the user id', function (ctx) {
      ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith(
        ctx.req.session
      )
    })

    it('should call the project history api', function (ctx) {
      ctx.fetchStreamWithResponse.should.have.been.calledWith(
        `${ctx.settings.apis.project_history.url}${ctx.req.url}`,
        {
          method: ctx.req.method,
          headers: {
            'X-User-Id': ctx.user_id,
          },
        }
      )
    })

    it('should pipe the response to the client', function (ctx) {
      expect(ctx.Stream.pipeline).to.have.been.calledWith(ctx.stream, ctx.res)
    })

    it('should propagate the appropriate headers', function (ctx) {
      expect(ctx.res.set).to.have.been.calledWith(
        'Content-Type',
        ctx.contentType
      )
      expect(ctx.res.set).to.have.been.calledWith(
        'Content-Length',
        ctx.contentLength
      )
    })
  })

  describe('proxyToHistoryApiAndInjectUserDetails', function () {
    beforeEach(async function (ctx) {
      ctx.req = { url: '/mock/url', method: 'POST' }
      ctx.res = { json: sinon.stub() }
      ctx.data = 'mock-data'
      ctx.dataWithUsers = 'mock-injected-data'
      ctx.fetchJson.resolves(ctx.data)
      ctx.HistoryManager.promises.injectUserDetails.resolves(ctx.dataWithUsers)
      await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails(
        ctx.req,
        ctx.res,
        ctx.next
      )
    })

    it('should get the user id', function (ctx) {
      ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith(
        ctx.req.session
      )
    })

    it('should call the project history api', function (ctx) {
      ctx.fetchJson.should.have.been.calledWith(
        `${ctx.settings.apis.project_history.url}${ctx.req.url}`,
        {
          method: ctx.req.method,
          headers: {
            'X-User-Id': ctx.user_id,
          },
        }
      )
    })

    it('should inject the user data', function (ctx) {
      ctx.HistoryManager.promises.injectUserDetails.should.have.been.calledWith(
        ctx.data
      )
    })

    it('should return the data with users to the client', function (ctx) {
      ctx.res.json.should.have.been.calledWith(ctx.dataWithUsers)
    })
  })

  describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () {
    beforeEach(async function (ctx) {
      ctx.url = '/mock/url'
      ctx.req = { url: ctx.url, method: 'POST' }
      ctx.res = { json: sinon.stub() }
      ctx.err = new RequestFailedError(ctx.url, {}, { status: 500 })
      ctx.fetchJson.rejects(ctx.err)
      await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails(
        ctx.req,
        ctx.res,
        ctx.next
      )
    })

    it('should not inject the user data', function (ctx) {
      ctx.HistoryManager.promises.injectUserDetails.should.not.have.been.called
    })

    it('should not return the data with users to the client', function (ctx) {
      ctx.res.json.should.not.have.been.called
    })

    it('should throw an error', function (ctx) {
      ctx.next.should.have.been.calledWith(ctx.err)
    })
  })

  describe('resyncProjectHistory', function () {
    describe('for a project without project-history enabled', function () {
      beforeEach(async function (ctx) {
        ctx.req = { params: { Project_id: ctx.project_id }, body: {} }
        ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }

        ctx.error = new Errors.ProjectHistoryDisabledError()
        ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.rejects(
          ctx.error
        )

        await ctx.HistoryController.resyncProjectHistory(
          ctx.req,
          ctx.res,
          ctx.next
        )
      })

      it('response with a 404', function (ctx) {
        ctx.res.sendStatus.should.have.been.calledWith(404)
      })
    })

    describe('for a project with project-history enabled', function () {
      beforeEach(async function (ctx) {
        ctx.req = { params: { Project_id: ctx.project_id }, body: {} }
        ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }

        await ctx.HistoryController.resyncProjectHistory(
          ctx.req,
          ctx.res,
          ctx.next
        )
      })

      it('sets an extended response timeout', function (ctx) {
        ctx.res.setTimeout.should.have.been.calledWith(6 * 60 * 1000)
      })

      it('resyncs the project', function (ctx) {
        ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.have.been.calledWith(
          ctx.project_id
        )
      })

      it('responds with a 204', function (ctx) {
        ctx.res.sendStatus.should.have.been.calledWith(204)
      })
    })
  })

  describe('requestBlob', function () {
    describe('With Range header', function () {
      beforeEach(async function (ctx) {
        ctx.req = {
          params: {
            project_id: ctx.project_id,
            hash: ctx.blobHash,
          },
          query: {},
          body: {},
          get: sinon.stub(),
        }
        ctx.req.get.withArgs('Range').returns('bytes=0-42')
        ctx.res = { setHeader: sinon.stub(), status: sinon.stub() }
        ctx.HistoryManager.promises.requestBlobWithProjectId.resolves({
          stream: null,
          contentLength: '43',
          contentRange: 'bytes 0-42/100',
        })
        await ctx.HistoryController.getBlob(ctx.req, ctx.res, ctx.next)
      })

      it('should forward the range request', function (ctx) {
        ctx.HistoryManager.promises.requestBlobWithProjectId.should.have.been.calledWith(
          sinon.match(val => val.toString() === ctx.project_id),
          ctx.blobHash,
          'GET',
          'bytes=0-42'
        )
      })

      it('should forward the Content-Range header', function (ctx) {
        ctx.res.setHeader.should.have.been.calledWith(
          'Content-Range',
          'bytes 0-42/100'
        )
      })

      it('should forward the Content-Length header', function (ctx) {
        ctx.res.setHeader.should.have.been.calledWith('Content-Length', '43')
      })

      it('should have status 206', function (ctx) {
        ctx.res.status.should.have.been.calledWith(206)
      })
    })

    describe('Without Range header', function () {
      beforeEach(async function (ctx) {
        ctx.req = {
          params: {
            project_id: ctx.project_id,
            hash: ctx.blobHash,
          },
          query: {},
          body: {},
          get: sinon.stub(),
        }
        ctx.req.get.withArgs('Range').returns(null)
        ctx.res = { setHeader: sinon.stub(), status: sinon.stub() }
        ctx.HistoryManager.promises.requestBlobWithProjectId.resolves({
          stream: null,
          contentLength: '100',
          range: null,
        })
        await ctx.HistoryController.getBlob(ctx.req, ctx.res, ctx.next)
      })

      it('should not have a Content-Range header', function (ctx) {
        expect(ctx.res.setHeader).to.not.have.been.calledWith(
          'Content-Range',
          sinon.match.string
        )
      })

      it('should forward the Content-Length header', function (ctx) {
        ctx.res.setHeader.should.have.been.calledWith('Content-Length', '100')
      })

      it('should not have status 206', function (ctx) {
        ctx.res.status.should.not.have.been.calledWith(206)
      })
    })
  })
})
