import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { expect } from 'chai'
import logger from '@overleaf/logger'
import { filterOutput } from './helpers/settings.mjs'
import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'

const lastUpdated = new Date(42)
const lastUpdatedBy = new ObjectId()
const lastUpdatedChanged = new Date(1337)

async function runScriptFind() {
  try {
    const result = await promisify(exec)(
      ['node', 'scripts/find_malformed_filetrees.mjs'].join(' ')
    )
    return result.stdout.split('\n').filter(filterOutput)
  } catch (error) {
    logger.error({ error }, 'script failed')
    throw error
  }
}

async function runScriptFix(instructions) {
  const adhocFile = instructions.map(entry => JSON.stringify(entry)).join('\n')
  try {
    return await promisify(exec)(
      [
        'node',
        'scripts/fix_malformed_filetree.mjs',
        `--logs=<(echo '${adhocFile}')`,
      ].join(' '),
      { shell: '/bin/bash' }
    )
  } catch (error) {
    logger.error({ error }, 'fix script failed unexpectedly')
    throw error
  }
}

const findProjects = () =>
  db.projects
    .find(
      {},
      {
        projection: {
          rootFolder: 1,
          _id: 1,
          version: 1,
          lastUpdated: 1,
          lastUpdatedBy: 1,
        },
      }
    )
    .toArray()

const projectId = new ObjectId()
const rootFolderId = new ObjectId()

const idDic = {}

const id = key => {
  if (!idDic[key]) {
    idDic[key] = new ObjectId()
  }
  return idDic[key]
}
const strId = key => {
  return idDic[key].toString()
}

const wellFormedFolder = name => ({
  _id: id(name),
  name,
  folders: [],
  docs: [],
  fileRefs: [],
})
const wellFormedDoc = name => ({ _id: id(name), name })
const wellFormedFileRef = name => ({ _id: id(name), name, hash: 'h' })

const wellFormedProject = {
  _id: projectId,
  rootFolder: [
    {
      _id: rootFolderId,
      name: 'rootFolder',
      folders: [wellFormedFolder('f00'), wellFormedFolder('f01')],
      docs: [wellFormedDoc('d00'), wellFormedDoc('d01')],
      fileRefs: [wellFormedFileRef('fr00'), wellFormedFileRef('fr01')],
    },
  ],
  lastUpdated,
  lastUpdatedBy,
}

const testCases = [
  ...[{}, { rootFolder: undefined }, { rootFolder: '1234' }].map(
    (project, idx) => ({
      name: `bad rootFolder ${idx + 1}`,
      project: { _id: projectId, ...project, lastUpdated, lastUpdatedBy },
      expectFind: [
        {
          _id: null,
          projectId: projectId.toString(),
          msg: 'bad file-tree path',
          reason: 'bad rootFolder',
          path: 'rootFolder',
        },
      ],
      // FIXME: This is a bug in the script.
      expectFixError: 'Unexpected mongo path: rootFolder',
    })
  ),

  {
    name: `missing rootFolder`,
    project: { _id: projectId, rootFolder: [], lastUpdated, lastUpdatedBy },
    expectFind: [
      {
        _id: null,
        projectId: projectId.toString(),
        msg: 'bad file-tree path',
        reason: 'missing rootFolder',
        path: 'rootFolder.0',
      },
    ],
    expectFixStdout:
      '"gracefulShutdownInitiated":false,"processedLines":1,"success":1,"alreadyProcessed":0,"hash":0,"failed":0,"unmatched":0',
    expectProject: updatedProject => {
      expect(updatedProject.rootFolder[0]._id).to.be.an.instanceOf(ObjectId)
      expect(updatedProject).to.deep.equal({
        _id: projectId,
        rootFolder: [
          {
            _id: updatedProject.rootFolder[0]._id,
            name: 'rootFolder',
            folders: [],
            fileRefs: [],
            docs: [],
          },
        ],
        lastUpdated: lastUpdatedChanged,
        lastUpdatedBy: null,
      })
    },
  },

  {
    name: 'empty folder',
    project: {
      _id: projectId,
      rootFolder: [{ _id: '1234' }],
      lastUpdated,
      lastUpdatedBy,
    },
    expectFind: [
      { reason: 'bad folder id', path: 'rootFolder.0._id' },
      { reason: 'bad folder name', path: 'rootFolder.0.name' },
      { reason: 'missing .folders', path: 'rootFolder.0.folders' },
      { reason: 'missing .docs', path: 'rootFolder.0.docs' },
      { reason: 'missing .fileRefs', path: 'rootFolder.0.fileRefs' },
    ].map(entry => ({
      ...entry,
      _id: '1234',
      msg: 'bad file-tree path',
      projectId: String(projectId),
    })),
    // FIXME: This is a bug in the script.
    expectFixError: 'Unexpected mongo path: rootFolder.0._id',
  },

  {
    name: 'missing fields',
    project: {
      _id: projectId,
      rootFolder: [{ _id: rootFolderId }],
      lastUpdated,
      lastUpdatedBy,
    },
    expectFind: [
      { reason: 'bad folder name', path: 'rootFolder.0.name' },
      { reason: 'missing .folders', path: 'rootFolder.0.folders' },
      { reason: 'missing .docs', path: 'rootFolder.0.docs' },
      { reason: 'missing .fileRefs', path: 'rootFolder.0.fileRefs' },
    ].map(entry => ({
      ...entry,
      _id: rootFolderId.toString(),
      msg: 'bad file-tree path',
      projectId: String(projectId),
    })),
    expectFixStdout:
      '"gracefulShutdownInitiated":false,"processedLines":4,"success":4,"alreadyProcessed":0,"hash":0,"failed":0,"unmatched":0',
    expectProject: updatedProject => {
      expect(updatedProject).to.deep.equal({
        _id: projectId,
        rootFolder: [
          {
            _id: rootFolderId,
            docs: [],
            fileRefs: [],
            folders: [],
            name: 'rootFolder',
          },
        ],
        lastUpdated: lastUpdatedChanged,
        lastUpdatedBy: null,
      })
    },
  },

  {
    name: 'bad folder, bad doc, bad fileRef',
    project: {
      _id: projectId,
      rootFolder: [
        {
          _id: rootFolderId,
          name: 'rootFolder',
          folders: [null],
          docs: [null],
          fileRefs: [null, null],
        },
      ],
      lastUpdated,
      lastUpdatedBy,
    },
    expectFind: [
      {
        path: 'rootFolder.0.folders.0',
        reason: 'bad folder',
      },
      {
        path: 'rootFolder.0.docs.0',
        reason: 'bad doc',
      },
      {
        path: 'rootFolder.0.fileRefs.0',
        reason: 'bad file',
      },
      {
        path: 'rootFolder.0.fileRefs.1',
        reason: 'bad file',
      },
    ].map(entry => ({
      ...entry,
      _id: rootFolderId.toString(),
      projectId: projectId.toString(),
      msg: 'bad file-tree path',
    })),
    expectFixStdout:
      '"gracefulShutdownInitiated":false,"processedLines":4,"success":1,"alreadyProcessed":3,"hash":0,"failed":0,"unmatched":0',
    expectProject: updatedProject => {
      expect(updatedProject).to.deep.equal({
        _id: projectId,
        rootFolder: [
          {
            _id: rootFolderId,
            name: 'rootFolder',
            docs: [],
            fileRefs: [],
            folders: [],
          },
        ],
        lastUpdated: lastUpdatedChanged,
        lastUpdatedBy: null,
      })
    },
  },

  {
    name: 'bad [folder|doc|fileRef] id',
    project: {
      _id: projectId,
      rootFolder: [
        {
          _id: rootFolderId,
          name: 'rootFolder',
          folders: [
            { _id: 123, name: 'file-a', folders: [], docs: [], fileRefs: [] },
            { name: 'file-b', folders: [], docs: [], fileRefs: [] },
          ],
          docs: [{ _id: '456', name: 'doc-a' }, { name: 'doc-b' }],
          fileRefs: [{ _id: null, name: 'ref-a' }, { name: 'ref-b' }],
        },
      ],
      lastUpdated,
      lastUpdatedBy,
    },
    expectFind: [
      { reason: 'bad folder id', path: 'rootFolder.0.folders.0._id', _id: 123 },
      { reason: 'bad folder id', path: 'rootFolder.0.folders.1._id' },
      { reason: 'bad doc id', path: 'rootFolder.0.docs.0._id', _id: '456' },
      { reason: 'bad doc id', path: 'rootFolder.0.docs.1._id' },
      { reason: 'bad file id', path: 'rootFolder.0.fileRefs.0._id', _id: null },
      { reason: 'bad file id', path: 'rootFolder.0.fileRefs.1._id' },
    ].map(entry => ({
      ...entry,
      projectId: projectId.toString(),
      msg: 'bad file-tree path',
    })),
    expectFixStdout:
      '"gracefulShutdownInitiated":false,"processedLines":6,"success":3,"alreadyProcessed":3,"hash":0,"failed":0,"unmatched":0',
    expectProject: updatedProject => {
      expect(updatedProject).to.deep.equal({
        _id: projectId,
        rootFolder: [
          {
            _id: rootFolderId,
            name: 'rootFolder',
            folders: [
              { _id: 123, name: 'file-a', folders: [], docs: [], fileRefs: [] },
              {
                _id: updatedProject.rootFolder[0].folders[1]._id,
                name: 'file-b',
                folders: [],
                docs: [],
                fileRefs: [],
              },
            ],
            docs: [{ _id: '456', name: 'doc-a' }],
            fileRefs: [],
          },
        ],
        lastUpdated: lastUpdatedChanged,
        lastUpdatedBy: null,
      })
    },
  },

  {
    name: 'bad [folder|doc|fileRef] name',
    project: {
      _id: projectId,
      rootFolder: [
        {
          _id: rootFolderId,
          name: 'rootFolder',
          folders: [
            { _id: id('f00'), folders: [], docs: [], fileRefs: [] },
            { _id: id('f01'), name: 8, folders: [], docs: [], fileRefs: [] },
          ],
          docs: [{ _id: id('d00') }, { _id: id('d01'), name: null }],
          fileRefs: [
            { _id: id('fr00'), hash: 'h' },
            { _id: id('fr01'), hash: 'h', name: [] },
          ],
        },
      ],
      lastUpdated,
      lastUpdatedBy,
    },
    expectFind: [
      {
        reason: 'bad folder name',
        path: 'rootFolder.0.folders.0.name',
        _id: strId('f00'),
      },
      {
        reason: 'bad folder name',
        path: 'rootFolder.0.folders.1.name',
        _id: strId('f01'),
      },
      {
        reason: 'bad doc name',
        path: 'rootFolder.0.docs.0.name',
        _id: strId('d00'),
      },
      {
        reason: 'bad doc name',
        path: 'rootFolder.0.docs.1.name',
        _id: strId('d01'),
      },
      {
        reason: 'bad file name',
        path: 'rootFolder.0.fileRefs.0.name',
        _id: strId('fr00'),
      },
      {
        reason: 'bad file name',
        path: 'rootFolder.0.fileRefs.1.name',
        _id: strId('fr01'),
      },
    ].map(entry => ({
      ...entry,
      projectId: projectId.toString(),
      msg: 'bad file-tree path',
    })),
    expectFixStdout:
      '"gracefulShutdownInitiated":false,"processedLines":6,"success":6,"alreadyProcessed":0,"hash":0,"failed":0,"unmatched":0',
    expectProject: updatedProject => {
      expect(updatedProject).to.deep.equal({
        _id: projectId,
        rootFolder: [
          {
            _id: rootFolderId,
            name: 'rootFolder',
            folders: [
              {
                _id: id('f00'),
                name: 'untitled',
                folders: [],
                docs: [],
                fileRefs: [],
              },
              {
                _id: id('f01'),
                name: 'untitled-1',
                folders: [],
                docs: [],
                fileRefs: [],
              },
            ],
            docs: [
              { _id: id('d00'), name: 'untitled' },
              { _id: id('d01'), name: 'untitled-1' },
            ],
            fileRefs: [
              { _id: id('fr00'), hash: 'h', name: 'untitled' },
              { _id: id('fr01'), hash: 'h', name: 'untitled-1' },
            ],
          },
        ],
        lastUpdated: lastUpdatedChanged,
        lastUpdatedBy: null,
      })
    },
  },

  {
    name: 'bad file hash',
    project: {
      ...wellFormedProject,
      rootFolder: [
        {
          ...wellFormedProject.rootFolder[0],
          fileRefs: [
            { _id: id('fa'), name: 'ref-a', hash: null },
            { _id: id('fb'), name: 'ref-b', hash: {} },
          ],
        },
      ],
      lastUpdated,
      lastUpdatedBy,
    },
    expectFind: [
      { path: 'rootFolder.0.fileRefs.0.hash', _id: strId('fa') },
      { path: 'rootFolder.0.fileRefs.1.hash', _id: strId('fb') },
    ].map(entry => ({
      ...entry,
      projectId: projectId.toString(),
      reason: 'bad file hash',
      msg: 'bad file-tree path',
    })),
    expectFixError: new RegExp(
      `Missing file hash: ${projectId.toString()}/${id('fa').toString()}`
    ),
  },

  {
    name: 'well formed filetrees',
    project: wellFormedProject,
    expectFind: [],
    expectFixStdout:
      '"gracefulShutdownInitiated":false,"processedLines":1,"success":0,"alreadyProcessed":0,"hash":0,"failed":0,"unmatched":0',
    expectProject: updatedProject => {
      expect(updatedProject).to.deep.equal(wellFormedProject)
    },
  },

  {
    name: 'bug: shifted arrays in filetree',
    project: {
      _id: projectId,
      rootFolder: [
        {
          _id: rootFolderId,
          name: 'rootFolder',
          folders: [null, null, { ...wellFormedFolder('f02'), name: null }],
          docs: [null, null, { ...wellFormedDoc('d02'), name: null }],
          fileRefs: [null, null, { ...wellFormedFileRef('fr02'), name: null }],
        },
      ],
      lastUpdated,
      lastUpdatedBy,
    },
    expectFind: [
      {
        _id: rootFolderId.toString(),
        path: 'rootFolder.0.folders.0',
        reason: 'bad folder',
      },
      {
        _id: rootFolderId.toString(),
        path: 'rootFolder.0.folders.1',
        reason: 'bad folder',
      },
      {
        _id: strId('f02'),
        path: 'rootFolder.0.folders.2.name',
        reason: 'bad folder name',
      },
      {
        _id: rootFolderId.toString(),
        path: 'rootFolder.0.docs.0',
        reason: 'bad doc',
      },
      {
        _id: rootFolderId.toString(),
        path: 'rootFolder.0.docs.1',
        reason: 'bad doc',
      },
      {
        _id: strId('d02'),
        path: 'rootFolder.0.docs.2.name',
        reason: 'bad doc name',
      },
      {
        _id: rootFolderId.toString(),
        path: 'rootFolder.0.fileRefs.0',
        reason: 'bad file',
      },
      {
        _id: rootFolderId.toString(),
        path: 'rootFolder.0.fileRefs.1',
        reason: 'bad file',
      },
      {
        _id: strId('fr02'),
        path: 'rootFolder.0.fileRefs.2.name',
        reason: 'bad file name',
      },
    ].map(entry => ({
      ...entry,
      projectId: projectId.toString(),
      msg: 'bad file-tree path',
    })),
    expectFixStdout:
      '"gracefulShutdownInitiated":false,"processedLines":9,"success":4,"alreadyProcessed":5,"hash":0,"failed":0,"unmatched":0',
    expectProject: updatedProject => {
      expect(updatedProject).to.deep.equal({
        _id: projectId,
        rootFolder: [
          {
            _id: rootFolderId,
            name: 'rootFolder',
            folders: [{ ...wellFormedFolder('f02'), name: 'untitled' }],
            docs: [{ ...wellFormedDoc('d02'), name: 'untitled' }],
            fileRefs: [{ ...wellFormedFileRef('fr02'), name: 'untitled' }],
          },
        ],
        lastUpdated: lastUpdatedChanged,
        lastUpdatedBy: null,
      })
    },
  },
  {
    name: 'bug: shifted arrays in filetree folder',
    project: {
      _id: projectId,
      rootFolder: [
        {
          _id: rootFolderId,
          name: 'rootFolder',
          folders: [
            null,
            null,
            {
              ...wellFormedFolder('f02'),
              name: 'folder 1',
              folders: [null, null, { ...wellFormedFolder('f022') }],
              docs: [null, null, { ...wellFormedDoc('d022'), name: null }],
              fileRefs: [
                null,
                null,
                { ...wellFormedFileRef('fr022'), name: null },
              ],
            },
          ],

          docs: [],
          fileRefs: [],
        },
      ],
      lastUpdated,
      lastUpdatedBy,
    },
    expectFind: [
      {
        _id: rootFolderId.toString(),
        path: 'rootFolder.0.folders.0',
        reason: 'bad folder',
      },
      {
        _id: rootFolderId.toString(),
        path: 'rootFolder.0.folders.1',
        reason: 'bad folder',
      },
      {
        _id: strId('f02'),
        path: 'rootFolder.0.folders.2.folders.0',
        reason: 'bad folder',
      },
      {
        _id: strId('f02'),
        path: 'rootFolder.0.folders.2.folders.1',
        reason: 'bad folder',
      },
      {
        _id: strId('f02'),
        path: 'rootFolder.0.folders.2.docs.0',
        reason: 'bad doc',
      },
      {
        _id: strId('f02'),
        path: 'rootFolder.0.folders.2.docs.1',
        reason: 'bad doc',
      },
      {
        _id: strId('d022'),
        path: 'rootFolder.0.folders.2.docs.2.name',
        reason: 'bad doc name',
      },
      {
        _id: strId('f02'),
        path: 'rootFolder.0.folders.2.fileRefs.0',
        reason: 'bad file',
      },
      {
        _id: strId('f02'),
        path: 'rootFolder.0.folders.2.fileRefs.1',
        reason: 'bad file',
      },
      {
        _id: strId('fr022'),
        path: 'rootFolder.0.folders.2.fileRefs.2.name',
        reason: 'bad file name',
      },
    ].map(entry => ({
      ...entry,
      projectId: projectId.toString(),
      msg: 'bad file-tree path',
    })),
    expectFixStdout:
      '"gracefulShutdownInitiated":false,"processedLines":10,"success":4,"alreadyProcessed":6,"hash":0,"failed":0,"unmatched":0',
    expectProject: updatedProject => {
      expect(updatedProject).to.deep.equal({
        _id: projectId,
        rootFolder: [
          {
            _id: rootFolderId,
            name: 'rootFolder',
            folders: [
              {
                ...wellFormedFolder('f02'),
                name: 'folder 1',
                docs: [
                  {
                    ...wellFormedDoc('d022'),
                    name: 'untitled',
                  },
                ],
                fileRefs: [
                  {
                    ...wellFormedFileRef('fr022'),
                    // FIXME: Make the names unique across different file types
                    name: 'untitled',
                  },
                ],
                folders: [
                  {
                    ...wellFormedFolder('f022'),
                    name: 'f022',
                    folders: [],
                    docs: [],
                    fileRefs: [],
                  },
                ],
              },
            ],
            docs: [],
            fileRefs: [],
          },
        ],
        lastUpdated: lastUpdatedChanged,
        lastUpdatedBy: null,
      })
    },
  },
]

describe('find_malformed_filetrees and fix_malformed_filetree scripts', function () {
  testCases.forEach(
    ({
      name,
      project,
      expectFind,
      expectFixStdout,
      expectFixError,
      expectProject,
    }) => {
      describe(name, function () {
        beforeEach(async function () {
          await db.projects.insertOne(project)
        })

        it('finds malformed filetree', async function () {
          const stdout = await runScriptFind()
          expect(stdout.map(line => JSON.parse(line))).to.deep.equal(expectFind)
        })

        if (expectFixError) {
          it('fails to fix malformed filetrees', async function () {
            await expect(runScriptFix(expectFind)).to.be.rejectedWith(
              expectFixError
            )
          })
        } else {
          it('fixes malformed filetrees', async function () {
            const { stdout } = await runScriptFix(expectFind)
            expect(expectFixStdout).to.be.a('string')
            expect(stdout).to.include(expectFixStdout)
            const [updatedProject] = await findProjects()
            if (updatedProject.lastUpdated > lastUpdated) {
              updatedProject.lastUpdated = lastUpdatedChanged
            }
            expectProject(updatedProject)
          })
        }
      })
    }
  )
})
