import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js'
import {
  fetchStreamWithResponse,
  RequestFailedError,
} from '@overleaf/fetch-utils'
import Path from 'node:path'
import { pipeline } from 'node:stream/promises'
import logger from '@overleaf/logger'
import ClsiCacheManager from './ClsiCacheManager.mjs'
import CompileController from './CompileController.mjs'
import { expressify } from '@overleaf/promise-utils'
import ClsiCacheHandler from './ClsiCacheHandler.mjs'
import ProjectGetter from '../Project/ProjectGetter.mjs'
import { MeteredStream } from '@overleaf/stream-utils'
import Metrics from '@overleaf/metrics'
import { z, zz } from '@overleaf/validation-tools'
import { parseReq } from '../../infrastructure/Validation.mjs'

const downloadFromCacheSchema = z.object({
  params: z.object({
    Project_id: zz.objectId(),
    editorBuildId: zz.editorBuildId(),
    filename: zz.filepath().refine(s => ClsiCacheHandler.isAllowedFilename(s), {
      message: 'path is not allowed',
    }),
  }),
})

/**
 * Download a file from a specific build on the clsi-cache.
 *
 * @param req
 * @param res
 * @return {Promise<*>}
 */
async function downloadFromCache(req, res) {
  const {
    params: { Project_id: projectId, editorBuildId, filename },
  } = parseReq(req, downloadFromCacheSchema)
  return await _downloadFromCacheWithParams(
    req,
    res,
    projectId,
    editorBuildId,
    filename
  )
}

/**
 * Download a file from a specific build on the clsi-cache.
 *
 * @param req
 * @param res
 * @param projectId
 * @param editorBuildId
 * @param filename
 * @param projectId
 * @return {Promise<*>}
 */
async function _downloadFromCacheWithParams(
  req,
  res,
  projectId,
  editorBuildId,
  filename
) {
  const userId = CompileController._getUserIdForCompile(req)
  const ac = new AbortController()
  let timer = setTimeout(() => ac.abort(), 10_000)
  let location, projectName
  try {
    ;[{ location }, { name: projectName }] = await Promise.all([
      ClsiCacheHandler.getOutputFile(
        projectId,
        userId,
        editorBuildId,
        filename,
        ac.signal
      ),
      ProjectGetter.promises.getProject(projectId, { name: 1 }),
    ])
  } catch (err) {
    clearTimeout(timer)
    if (err instanceof NotFoundError) {
      // res.sendStatus() sends a description of the status as body.
      // Using res.status().end() avoids sending that fake body.
      return res.status(404).end()
    } else {
      throw err
    }
  }

  let stream, response
  try {
    ;({ stream, response } = await fetchStreamWithResponse(location, {
      signal: ac.signal,
    }))
  } finally {
    clearTimeout(timer)
  }
  if (req.destroyed) {
    // The client has disconnected already, avoid trying to write into the broken connection.
    stream.destroy(new Error('user aborted the request'))
    return
  }

  for (const key of ['Content-Length', 'Content-Type']) {
    if (response.headers.has(key)) res.setHeader(key, response.headers.get(key))
  }
  const ext = Path.extname(filename)
  res.attachment(
    ext === '.pdf'
      ? `${CompileController._getSafeProjectName({ name: projectName })}.pdf`
      : filename
  )
  // Downloads can take a while on a slow connection, increase timeouts to 10min
  const TEN_MINUTES_IN_MS = 10 * 60 * 1000
  res.setTimeout(TEN_MINUTES_IN_MS)
  timer = setTimeout(() => ac.abort(), TEN_MINUTES_IN_MS)

  // Disable buffering in nginx
  res.setHeader('X-Accel-Buffering', 'no')

  try {
    res.writeHead(response.status)
    await pipeline(
      stream,
      new MeteredStream(Metrics, 'clsi_cache_egress', {
        path: ClsiCacheHandler.getEgressLabel(filename),
      }),
      res
    )
  } catch (err) {
    const reqAborted = Boolean(req.destroyed)
    const streamingStarted = Boolean(res.headersSent)
    if (!streamingStarted) {
      if (err instanceof RequestFailedError) {
        res.sendStatus(err.response.status)
      } else {
        res.sendStatus(500)
      }
    }
    if (
      streamingStarted &&
      reqAborted &&
      (err.code === 'ERR_STREAM_PREMATURE_CLOSE' ||
        err.code === 'ERR_STREAM_UNABLE_TO_PIPE')
    ) {
      // Ignore noisy spurious error
      return
    }
    logger.warn(
      {
        err,
        projectId,
        location,
        filename,
        reqAborted,
        streamingStarted,
      },
      'CLSI-cache proxy error'
    )
  } finally {
    clearTimeout(timer)
  }
}

/**
 * Prepare a compile response from the clsi-cache.
 *
 * @param req
 * @param res
 * @return {Promise<void>}
 */
async function getLatestBuildFromCache(req, res) {
  const { Project_id: projectId } = req.params
  const userId = CompileController._getUserIdForCompile(req)
  try {
    const {
      zone,
      outputFiles,
      compileGroup,
      clsiServerId,
      clsiCacheShard,
      options,
      stats,
      timings,
    } = await ClsiCacheManager.getLatestCompileResult(projectId, userId)

    let { pdfCachingMinChunkSize, pdfDownloadDomain } =
      await CompileController._getSplitTestOptions(req, res)
    pdfDownloadDomain += `/zone/${zone}`
    res.json({
      fromCache: true,
      status: 'success',
      outputFiles,
      compileGroup,
      clsiServerId,
      clsiCacheShard,
      pdfDownloadDomain,
      pdfCachingMinChunkSize,
      options,
      stats,
      timings,
    })
  } catch (err) {
    if (err instanceof NotFoundError) {
      res.sendStatus(404)
    } else if (err instanceof ResourceGoneError) {
      res.sendStatus(410)
    } else {
      throw err
    }
  }
}

export default {
  _downloadFromCacheWithParams,
  downloadFromCache: expressify(downloadFromCache),
  getLatestBuildFromCache: expressify(getLatestBuildFromCache),
}
