const MockWebApi = require('./helpers/MockWebApi')
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
const { promisify } = require('node:util')
const { exec } = require('node:child_process')
const { expect } = require('chai')
const Settings = require('@overleaf/settings')
const fs = require('node:fs')
const Path = require('node:path')
const MockDocstoreApi = require('./helpers/MockDocstoreApi')
const sinon = require('sinon')

const rclient = require('@overleaf/redis-wrapper').createClient(
  Settings.redis.documentupdater
)

describe('CheckRedisMongoSyncState', function () {
  beforeEach(async function () {
    await DocUpdaterApp.ensureRunning()
  })
  beforeEach(async function () {
    await rclient.flushall()
  })

  let peekDocumentInDocstore
  beforeEach(function () {
    peekDocumentInDocstore = sinon.spy(MockDocstoreApi, 'peekDocument')
  })
  afterEach(function () {
    peekDocumentInDocstore.restore()
  })

  async function runScript(options) {
    let result
    try {
      result = await promisify(exec)(
        Object.entries(options)
          .map(([key, value]) => `${key}=${value}`)
          .concat(['node', 'scripts/check_redis_mongo_sync_state.js'])
          .join(' ')
      )
    } catch (error) {
      // includes details like exit code, stdErr and stdOut
      return error
    }
    result.code = 0
    return result
  }

  describe('without projects', function () {
    it('should work when in sync', async function () {
      const result = await runScript({})
      expect(result.code).to.equal(0)
      expect(result.stdout).to.include('Processed 0 projects')
      expect(result.stdout).to.include(
        'Found 0 projects with 0 out of sync docs'
      )
    })
  })

  describe('with a project', function () {
    let projectId, docId
    beforeEach(async function () {
      projectId = DocUpdaterClient.randomId()
      docId = DocUpdaterClient.randomId()
      MockWebApi.insertDoc(projectId, docId, {
        lines: ['mongo', 'lines'],
        version: 1,
      })
      await DocUpdaterClient.preloadDoc(projectId, docId)
    })

    it('should work when in sync', async function () {
      const result = await runScript({})
      expect(result.code).to.equal(0)
      expect(result.stdout).to.include('Processed 1 projects')
      expect(result.stdout).to.include(
        'Found 0 projects with 0 out of sync docs'
      )

      expect(peekDocumentInDocstore).to.not.have.been.called
    })

    describe('with out of sync lines', function () {
      beforeEach(function () {
        MockWebApi.insertDoc(projectId, docId, {
          lines: ['updated', 'mongo', 'lines'],
          version: 1,
        })
      })

      it('should detect the out of sync state', async function () {
        const result = await runScript({})
        expect(result.code).to.equal(1)
        expect(result.stdout).to.include('Processed 1 projects')
        expect(result.stdout).to.include(
          'Found 1 projects with 1 out of sync docs'
        )
      })
    })

    describe('with out of sync ranges', function () {
      beforeEach(function () {
        MockWebApi.insertDoc(projectId, docId, {
          lines: ['mongo', 'lines'],
          version: 1,
          ranges: { changes: ['FAKE CHANGE'] },
        })
      })

      it('should detect the out of sync state', async function () {
        const result = await runScript({})
        expect(result.code).to.equal(1)
        expect(result.stdout).to.include('Processed 1 projects')
        expect(result.stdout).to.include(
          'Found 1 projects with 1 out of sync docs'
        )
      })
    })

    describe('with out of sync version', function () {
      beforeEach(function () {
        MockWebApi.insertDoc(projectId, docId, {
          lines: ['mongo', 'lines'],
          version: 2,
        })
      })

      it('should detect the out of sync state', async function () {
        const result = await runScript({})
        expect(result.code).to.equal(1)
        expect(result.stdout).to.include('Processed 1 projects')
        expect(result.stdout).to.include(
          'Found 1 projects with 1 out of sync docs'
        )
      })

      it('should auto-fix the out of sync state', async function () {
        const result = await runScript({
          AUTO_FIX_VERSION_MISMATCH: 'true',
        })
        expect(result.code).to.equal(0)
        expect(result.stdout).to.include('Processed 1 projects')
        expect(result.stdout).to.include(
          'Found 0 projects with 0 out of sync docs'
        )
      })
    })

    describe('with a project', function () {
      let projectId2, docId2
      beforeEach(async function () {
        projectId2 = DocUpdaterClient.randomId()
        docId2 = DocUpdaterClient.randomId()
        MockWebApi.insertDoc(projectId2, docId2, {
          lines: ['mongo', 'lines'],
          version: 1,
        })
        await DocUpdaterClient.preloadDoc(projectId2, docId2)
      })

      it('should work when in sync', async function () {
        const result = await runScript({})
        expect(result.code).to.equal(0)
        expect(result.stdout).to.include('Processed 2 projects')
        expect(result.stdout).to.include(
          'Found 0 projects with 0 out of sync docs'
        )
      })

      describe('with one out of sync', function () {
        beforeEach(function () {
          MockWebApi.insertDoc(projectId, docId, {
            lines: ['updated', 'mongo', 'lines'],
            version: 1,
          })
        })

        it('should detect one project out of sync', async function () {
          const result = await runScript({})
          expect(result.code).to.equal(1)
          expect(result.stdout).to.include('Processed 2 projects')
          expect(result.stdout).to.include(
            'Found 1 projects with 1 out of sync docs'
          )
        })

        it('should write differences to disk', async function () {
          const FOLDER = '/tmp/folder'
          await fs.promises.rm(FOLDER, { recursive: true, force: true })
          const result = await runScript({
            WRITE_CONTENT: 'true',
            FOLDER,
          })
          expect(result.code).to.equal(1)
          expect(result.stdout).to.include('Processed 2 projects')
          expect(result.stdout).to.include(
            'Found 1 projects with 1 out of sync docs'
          )

          const dir = Path.join(FOLDER, projectId, docId)
          expect(await fs.promises.readdir(FOLDER)).to.deep.equal([projectId])
          expect(await fs.promises.readdir(dir)).to.deep.equal([
            'mongo-snapshot.txt',
            'redis-snapshot.txt',
          ])
          expect(
            await fs.promises.readFile(
              Path.join(dir, 'mongo-snapshot.txt'),
              'utf-8'
            )
          ).to.equal('updated\nmongo\nlines')
          expect(
            await fs.promises.readFile(
              Path.join(dir, 'redis-snapshot.txt'),
              'utf-8'
            )
          ).to.equal('mongo\nlines')
        })
      })

      describe('with both out of sync', function () {
        beforeEach(function () {
          MockWebApi.insertDoc(projectId, docId, {
            lines: ['updated', 'mongo', 'lines'],
            version: 1,
          })
          MockWebApi.insertDoc(projectId2, docId2, {
            lines: ['updated2', 'mongo', 'lines'],
            version: 1,
          })
        })

        it('should detect both projects out of sync', async function () {
          const result = await runScript({})
          expect(result.code).to.equal(1)
          expect(result.stdout).to.include('Processed 2 projects')
          expect(result.stdout).to.include(
            'Found 2 projects with 2 out of sync docs'
          )
        })
      })
    })
  })

  describe('with more projects than the LIMIT', function () {
    for (let i = 0; i < 20; i++) {
      beforeEach(async function () {
        const projectId = DocUpdaterClient.randomId()
        const docId = DocUpdaterClient.randomId()
        MockWebApi.insertDoc(projectId, docId, {
          lines: ['mongo', 'lines'],
          version: 1,
        })
        await DocUpdaterClient.preloadDoc(projectId, docId)
      })
    }

    it('should flag limit', async function () {
      const result = await runScript({ LIMIT: '4' })
      expect(result.code).to.equal(2)
      // A redis SCAN may return more than COUNT (aka LIMIT) entries. Match loosely.
      expect(result.stdout).to.match(/Processed \d+ projects/)
      expect(result.stderr).to.include(
        'Found too many un-flushed projects (LIMIT=4). Please fix the reported projects first, then try again.'
      )
    })

    it('should continue with auto-flush', async function () {
      const result = await runScript({
        LIMIT: '4',
        FLUSH_IN_SYNC_PROJECTS: 'true',
      })
      expect(result.code).to.equal(0)
      expect(result.stdout).to.include('Processed 20 projects')
    })
  })

  describe('with partially deleted doc', function () {
    let projectId, docId
    beforeEach(async function () {
      projectId = DocUpdaterClient.randomId()
      docId = DocUpdaterClient.randomId()
      MockWebApi.insertDoc(projectId, docId, {
        lines: ['mongo', 'lines'],
        version: 1,
      })
      MockDocstoreApi.insertDoc(projectId, docId, {
        lines: ['mongo', 'lines'],
        version: 1,
      })
      await DocUpdaterClient.preloadDoc(projectId, docId)
      MockWebApi.clearDocs()
    })
    describe('with only the file-tree entry deleted', function () {
      it('should flag the partial deletion', async function () {
        const result = await runScript({})
        expect(result.code).to.equal(0)
        expect(result.stdout).to.include('Processed 1 projects')
        expect(result.stdout).to.include(
          `Found partially deleted doc ${docId} in project ${projectId}: use AUTO_FIX_PARTIALLY_DELETED_DOC_METADATA=true to fix metadata`
        )
        expect(result.stdout).to.include(
          'Found 0 projects with 0 out of sync docs'
        )
        expect(MockDocstoreApi.getDoc(projectId, docId)).to.not.include({
          deleted: true,
          name: 'c.tex',
        })
        expect(peekDocumentInDocstore).to.have.been.called
      })
      it('should autofix the partial deletion', async function () {
        const result = await runScript({
          AUTO_FIX_PARTIALLY_DELETED_DOC_METADATA: 'true',
        })
        expect(result.code).to.equal(0)
        expect(result.stdout).to.include('Processed 1 projects')
        expect(result.stdout).to.include(
          `Found partially deleted doc ${docId} in project ${projectId}: fixing metadata`
        )
        expect(result.stdout).to.include(
          'Found 0 projects with 0 out of sync docs'
        )

        expect(MockDocstoreApi.getDoc(projectId, docId)).to.include({
          deleted: true,
          name: 'c.tex',
        })

        const result2 = await runScript({})
        expect(result2.code).to.equal(0)
        expect(result2.stdout).to.include('Processed 1 projects')
        expect(result2.stdout).to.not.include(
          `Found partially deleted doc ${docId} in project ${projectId}`
        )
        expect(result2.stdout).to.include(
          'Found 0 projects with 0 out of sync docs'
        )
      })
    })
    describe('with docstore metadata updated', function () {
      beforeEach(async function () {
        await MockDocstoreApi.patchDocument(projectId, docId, {
          deleted: true,
          deletedAt: new Date(),
          name: 'c.tex',
        })
      })

      it('should work when in sync', async function () {
        const result = await runScript({})
        expect(result.code).to.equal(0)
        expect(result.stdout).to.include('Processed 1 projects')
        expect(result.stdout).to.not.include(
          `Found partially deleted doc ${docId} in project ${projectId}`
        )
        expect(result.stdout).to.include(
          'Found 0 projects with 0 out of sync docs'
        )
        expect(peekDocumentInDocstore).to.have.been.called
      })
    })
  })
})
