/**
 * Try to recover a zip of the latest version of a project using only data in
 * GCS, where this data may have been (recently) hard deleted (i.e. may exist
 * wholely or in part as non-current versions). This should be able to
 * retrieve the latest content of a project up to 180 days after it was
 * deleted.
 *
 * Usage:
 * node recover_zip.js [--verbose] <HISTORY_ID> <HISTORY_ID> ...
 *
 * Output:
 * Signed URL(s) for the uploaded zip files. Note that these are valid for
 * only 24h, to match the lifecycle rule on the zip bucket.
 */

const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const util = require('node:util')

// Something is registering 11 listeners, over the limit of 10, which generates
// a lot of warning noise.
require('node:events').EventEmitter.defaultMaxListeners = 11

const config = require('config')
// We depend on this via object-persistor.
// eslint-disable-next-line import/no-extraneous-dependencies
const { Storage } = require('@google-cloud/storage')
const isValidUtf8 = require('utf-8-validate')

const core = require('overleaf-editor-core')
const projectKey = require('../lib/project_key')
const streams = require('../lib/streams')
const ProjectArchive = require('../lib/project_archive')

const {
  values: { verbose: VERBOSE },
  positionals: HISTORY_IDS,
} = util.parseArgs({
  options: {
    verbose: {
      type: 'boolean',
      default: false,
    },
  },
  allowPositionals: true,
})

if (HISTORY_IDS.length === 0) {
  console.error('no history IDs; see usage')
  process.exit(1)
}

async function listDeletedChunks(historyId) {
  const bucketName = config.get('chunkStore.bucket')
  const storage = new Storage()
  const [files] = await storage.bucket(bucketName).getFiles({
    prefix: projectKey.format(historyId),
    versions: true,
  })
  return files
}

async function findLatestChunk(historyId) {
  const files = await listDeletedChunks(historyId)
  if (files.length === 0) return null
  files.sort((a, b) => {
    if (a.name < b.name) return -1
    if (a.name > b.name) return 1
    return 0
  })
  return files[files.length - 1]
}

async function downloadLatestChunk(tmp, historyId) {
  const latestChunkFile = await findLatestChunk(historyId)
  if (!latestChunkFile) throw new Error('no chunk found to recover')

  const destination = path.join(tmp, 'latest.json')
  await latestChunkFile.download({ destination })
  return destination
}

async function loadHistory(historyPathname) {
  const data = await fs.promises.readFile(historyPathname)
  const rawHistory = JSON.parse(data)
  return core.History.fromRaw(rawHistory)
}

async function loadChunk(historyPathname, blobStore) {
  const history = await loadHistory(historyPathname)

  const blobHashes = new Set()
  history.findBlobHashes(blobHashes)

  await blobStore.fetchBlobs(blobHashes)
  await history.loadFiles('lazy', blobStore)

  return new core.Chunk(history, 0)
}

// TODO: it would be nice to export / expose this from BlobStore;
// currently this is a copy of the method there.
async function getStringLengthOfFile(byteLength, pathname) {
  // We have to read the file into memory to get its UTF-8 length, so don't
  // bother for files that are too large for us to edit anyway.
  if (byteLength > core.Blob.MAX_EDITABLE_BYTE_LENGTH_BOUND) {
    return null
  }

  // We need to check if the file contains nonBmp or null characters
  let data = await fs.promises.readFile(pathname)
  if (!isValidUtf8(data)) return null
  data = data.toString()
  if (data.length > core.TextOperation.MAX_STRING_LENGTH) return null
  if (core.util.containsNonBmpChars(data)) return null
  if (data.indexOf('\x00') !== -1) return null
  return data.length
}

class RecoveryBlobStore {
  constructor(historyId, tmp) {
    this.historyId = historyId
    this.tmp = tmp
    this.blobs = new Map()
  }

  async fetchBlobs(blobHashes) {
    for await (const blobHash of blobHashes) {
      await this.fetchBlob(blobHash)
    }
  }

  async fetchBlob(hash) {
    if (this.blobs.has(hash)) return

    if (VERBOSE) console.log('fetching blob', hash)

    const bucketName = config.get('blobStore.projectBucket')
    const storage = new Storage()
    const [files] = await storage.bucket(bucketName).getFiles({
      prefix: this.makeProjectBlobKey(hash),
      versions: true,
    })

    const destination = this.getBlobPathname(hash)

    if (files.length === 0) {
      await this.fetchGlobalBlob(hash, destination)
    } else if (files.length === 1) {
      await files[0].download({ destination })
    } else {
      throw new Error('Multiple versions of blob ' + hash)
    }

    this.blobs.set(hash, await this.makeBlob(hash, destination))
  }

  async fetchGlobalBlob(hash, destination) {
    const bucketName = config.get('blobStore.globalBucket')
    const storage = new Storage()
    const file = storage.bucket(bucketName).file(this.makeGlobalBlobKey(hash))
    await file.download({ destination })
  }

  async makeBlob(hash, pathname) {
    const stat = await fs.promises.stat(pathname)
    const byteLength = stat.size
    const stringLength = await getStringLengthOfFile(byteLength, pathname)
    return new core.Blob(hash, byteLength, stringLength)
  }

  async getString(hash) {
    const stream = await this.getStream(hash)
    const buffer = await streams.readStreamToBuffer(stream)
    return buffer.toString()
  }

  async getStream(hash) {
    return fs.createReadStream(this.getBlobPathname(hash))
  }

  async getBlob(hash) {
    return this.blobs.get(hash)
  }

  getBlobPathname(hash) {
    return path.join(this.tmp, hash)
  }

  makeGlobalBlobKey(hash) {
    return `${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4)}`
  }

  makeProjectBlobKey(hash) {
    return `${projectKey.format(this.historyId)}/${hash.slice(
      0,
      2
    )}/${hash.slice(2)}`
  }
}

async function uploadZip(historyId, zipPathname) {
  const bucketName = config.get('zipStore.bucket')
  const deadline = 24 * 3600 * 1000 // lifecycle limit on the zips bucket
  const storage = new Storage()
  const destination = `${historyId}-recovered.zip`
  await storage.bucket(bucketName).upload(zipPathname, { destination })

  const signedUrls = await storage
    .bucket(bucketName)
    .file(destination)
    .getSignedUrl({
      version: 'v4',
      action: 'read',
      expires: Date.now() + deadline,
    })

  return signedUrls[0]
}

async function restoreProject(historyId) {
  const tmp = await fs.promises.mkdtemp(
    path.join(os.tmpdir(), historyId.toString())
  )
  if (VERBOSE) console.log('recovering', historyId, 'in', tmp)

  const latestJsonPathname = await downloadLatestChunk(tmp, historyId)
  const blobStore = new RecoveryBlobStore(historyId, tmp)
  const chunk = await loadChunk(latestJsonPathname, blobStore)

  const snapshot = chunk.getSnapshot()
  for (const change of chunk.getChanges()) {
    change.applyTo(snapshot)
  }

  if (VERBOSE) console.log('zipping', historyId)

  const zipPathname = path.join(tmp, `${historyId}.zip`)
  const zipTimeoutMs = 60 * 1000
  const archive = new ProjectArchive(snapshot, zipTimeoutMs)
  await archive.writeZip(blobStore, zipPathname)

  if (VERBOSE) console.log('uploading', historyId)

  return await uploadZip(historyId, zipPathname)
}

async function main() {
  for (const historyId of HISTORY_IDS) {
    const signedUrl = await restoreProject(historyId)
    console.log(signedUrl)
  }
}
main().catch(console.error)
