'use strict'

const { PassThrough } = require('node:stream')
const path = require('node:path')
const { fileURLToPath } = require('node:url')
const { statSync } = require('node:fs')
const { glob } = require('glob')
const fp = require('fastify-plugin')
const send = require('@fastify/send')
const encodingNegotiator = require('@fastify/accept-negotiator')
const contentDisposition = require('content-disposition')

const dirList = require('./lib/dirList')

const endForwardSlashRegex = /\/$/u
const asteriskRegex = /\*/gu

const supportedEncodings = ['br', 'gzip', 'deflate']
send.mime.default_type = 'application/octet-stream'

async function fastifyStatic (fastify, opts) {
  opts.root = normalizeRoot(opts.root)
  checkRootPathForErrors(fastify, opts.root)

  const setHeaders = opts.setHeaders
  if (setHeaders !== undefined && typeof setHeaders !== 'function') {
    throw new TypeError('The `setHeaders` option must be a function')
  }

  const invalidDirListOpts = dirList.validateOptions(opts)
  if (invalidDirListOpts) {
    throw invalidDirListOpts
  }

  if (opts.dotfiles === undefined) {
    opts.dotfiles = 'allow'
  }

  const sendOptions = {
    root: opts.root,
    acceptRanges: opts.acceptRanges,
    cacheControl: opts.cacheControl,
    dotfiles: opts.dotfiles,
    etag: opts.etag,
    extensions: opts.extensions,
    immutable: opts.immutable,
    index: opts.index,
    lastModified: opts.lastModified,
    maxAge: opts.maxAge
  }

  let prefix = opts.prefix ?? (opts.prefix = '/')

  if (!opts.prefixAvoidTrailingSlash) {
    prefix =
      prefix[prefix.length - 1] === '/'
        ? prefix
        : prefix + '/'
  }

  // Set the schema hide property if defined in opts or true by default
  const routeOpts = {
    constraints: opts.constraints,
    schema: {
      hide: opts.schemaHide !== undefined ? opts.schemaHide : true
    },
    errorHandler (error, request, reply) {
      if (error?.code === 'ERR_STREAM_PREMATURE_CLOSE') {
        reply.request.raw.destroy()
        return
      }

      fastify.errorHandler(error, request, reply)
    }
  }

  if (opts.decorateReply !== false) {
    fastify.decorateReply('sendFile', function (filePath, rootPath, options) {
      const opts = typeof rootPath === 'object' ? rootPath : options
      const root = typeof rootPath === 'string' ? rootPath : opts && opts.root
      pumpSendToReply(
        this.request,
        this,
        filePath,
        root || sendOptions.root,
        0,
        opts
      )
      return this
    })

    fastify.decorateReply(
      'download',
      function (filePath, fileName, options = {}) {
        const { root, ...opts } =
          typeof fileName === 'object' ? fileName : options
        fileName = typeof fileName === 'string' ? fileName : filePath

        // Set content disposition header
        this.header('content-disposition', contentDisposition(fileName))

        pumpSendToReply(this.request, this, filePath, root, 0, opts)

        return this
      }
    )
  }

  if (opts.serve !== false) {
    if (opts.wildcard && typeof opts.wildcard !== 'boolean') {
      throw new Error('"wildcard" option must be a boolean')
    }
    if (opts.wildcard === undefined || opts.wildcard === true) {
      fastify.route({
        ...routeOpts,
        method: ['HEAD', 'GET'],
        path: prefix + '*',
        handler (req, reply) {
          pumpSendToReply(req, reply, '/' + req.params['*'], sendOptions.root)
        }
      })
      if (opts.redirect === true && prefix !== opts.prefix) {
        fastify.get(opts.prefix, routeOpts, (req, reply) => {
          reply.redirect(301, getRedirectUrl(req.raw.url))
        })
      }
    } else {
      const indexes = opts.index === undefined ? ['index.html'] : [].concat(opts.index)
      const indexDirs = new Map()
      const routes = new Set()

      const roots = Array.isArray(sendOptions.root) ? sendOptions.root : [sendOptions.root]
      for (let rootPath of roots) {
        rootPath = rootPath.split(path.win32.sep).join(path.posix.sep)
        !rootPath.endsWith('/') && (rootPath += '/')
        const files = await glob('**/**', {
          cwd: rootPath, absolute: false, follow: true, nodir: true, dot: opts.serveDotFiles
        })

        for (let file of files) {
          file = file.split(path.win32.sep).join(path.posix.sep)
          const route = prefix + file

          if (routes.has(route)) {
            continue
          }

          routes.add(route)

          setUpHeadAndGet(routeOpts, route, `/${file}`, rootPath)

          const key = path.posix.basename(route)
          if (indexes.includes(key) && !indexDirs.has(key)) {
            indexDirs.set(path.posix.dirname(route), rootPath)
          }
        }
      }

      for (const [dirname, rootPath] of indexDirs.entries()) {
        const pathname = dirname + (dirname.endsWith('/') ? '' : '/')
        const file = '/' + pathname.replace(prefix, '')
        setUpHeadAndGet(routeOpts, pathname, file, rootPath)

        if (opts.redirect === true) {
          setUpHeadAndGet(routeOpts, pathname.replace(endForwardSlashRegex, ''), file.replace(endForwardSlashRegex, ''), rootPath)
        }
      }
    }
  }

  const allowedPath = opts.allowedPath

  function pumpSendToReply (
    request,
    reply,
    pathname,
    rootPath,
    rootPathOffset = 0,
    pumpOptions,
    checkedEncodings
  ) {
    const pathnameOrig = pathname
    const options = Object.assign({}, sendOptions, pumpOptions)

    if (rootPath) {
      if (Array.isArray(rootPath)) {
        options.root = rootPath[rootPathOffset]
      } else {
        options.root = rootPath
      }
    }

    if (allowedPath && !allowedPath(pathname, options.root, request)) {
      return reply.callNotFound()
    }

    let encoding
    let pathnameForSend = pathname

    if (opts.preCompressed) {
      /**
       * We conditionally create this structure to track our attempts
       * at sending pre-compressed assets
       */
      if (!checkedEncodings) {
        checkedEncodings = new Set()
      }

      encoding = getEncodingHeader(request.headers, checkedEncodings)

      if (encoding) {
        if (pathname.endsWith('/')) {
          pathname = findIndexFile(pathname, options.root, options.index)
          if (!pathname) {
            return reply.callNotFound()
          }
          pathnameForSend = pathnameForSend + pathname + '.' + getEncodingExtension(encoding)
        } else {
          pathnameForSend = pathname + '.' + getEncodingExtension(encoding)
        }
      }
    }

    // `send(..., path, ...)` will URI-decode path so we pass an encoded path here
    const stream = send(request.raw, encodeURI(pathnameForSend), options)
    let resolvedFilename
    stream.on('file', function (file) {
      resolvedFilename = file
    })

    const wrap = new PassThrough({
      flush (cb) {
        this.finished = true
        if (reply.raw.statusCode === 304) {
          reply.send('')
        }
        cb()
      }
    })

    wrap.getHeader = reply.getHeader.bind(reply)
    wrap.setHeader = reply.header.bind(reply)
    wrap.removeHeader = () => {}
    wrap.finished = false

    Object.defineProperty(wrap, 'filename', {
      get () {
        return resolvedFilename
      }
    })
    Object.defineProperty(wrap, 'statusCode', {
      get () {
        return reply.raw.statusCode
      },
      set (code) {
        reply.code(code)
      }
    })

    if (request.method === 'HEAD') {
      wrap.on('finish', reply.send.bind(reply))
    } else {
      wrap.on('pipe', function () {
        if (encoding) {
          reply.header('content-type', getContentType(pathname))
          reply.header('content-encoding', encoding)
        }
        reply.send(wrap)
      })
    }

    if (setHeaders !== undefined) {
      stream.on('headers', setHeaders)
    }

    stream.on('directory', function (_, path) {
      if (opts.list) {
        dirList.send({
          reply,
          dir: path,
          options: opts.list,
          route: pathname,
          prefix,
          dotfiles: opts.dotfiles
        }).catch((err) => reply.send(err))
        return
      }

      if (opts.redirect === true) {
        try {
          reply.redirect(301, getRedirectUrl(request.raw.url))
        } catch (error) {
          // the try-catch here is actually unreachable, but we keep it for safety and prevent DoS attack
          /* istanbul ignore next */
          reply.send(error)
        }
      } else {
        // if is a directory path without a trailing slash, and has an index file, reply as if it has a trailing slash
        if (!pathname.endsWith('/') && findIndexFile(pathname, options.root, options.index)) {
          return pumpSendToReply(
            request,
            reply,
            pathname + '/',
            rootPath,
            undefined,
            undefined,
            checkedEncodings
          )
        }

        reply.callNotFound()
      }
    })

    stream.on('error', function (err) {
      if (err.code === 'ENOENT') {
        // when preCompress is enabled and the path is a directory without a trailing slash
        if (opts.preCompressed && encoding) {
          const indexPathname = findIndexFile(pathname, options.root, options.index)
          if (indexPathname) {
            return pumpSendToReply(
              request,
              reply,
              pathname + '/',
              rootPath,
              undefined,
              undefined,
              checkedEncodings
            )
          }
        }

        // if file exists, send real file, otherwise send dir list if name match
        if (opts.list && dirList.handle(pathname, opts.list)) {
          dirList.send({
            reply,
            dir: dirList.path(opts.root, pathname),
            options: opts.list,
            route: pathname,
            prefix,
            dotfiles: opts.dotfiles
          }).catch((err) => reply.send(err))
          return
        }

        // root paths left to try?
        if (Array.isArray(rootPath) && rootPathOffset < (rootPath.length - 1)) {
          return pumpSendToReply(request, reply, pathname, rootPath, rootPathOffset + 1)
        }

        if (opts.preCompressed && !checkedEncodings.has(encoding)) {
          checkedEncodings.add(encoding)
          return pumpSendToReply(
            request,
            reply,
            pathnameOrig,
            rootPath,
            rootPathOffset,
            undefined,
            checkedEncodings
          )
        }

        return reply.callNotFound()
      }

      // The `send` library terminates the request with a 404 if the requested
      // path contains a dotfile and `send` is initialized with `{dotfiles:
      // 'ignore'}`. `send` aborts the request before getting far enough to
      // check if the file exists (hence, a 404 `NotFoundError` instead of
      // `ENOENT`).
      // https://github.com/pillarjs/send/blob/de073ed3237ade9ff71c61673a34474b30e5d45b/index.js#L582
      if (err.status === 404) {
        return reply.callNotFound()
      }

      reply.send(err)
    })

    // we cannot use pump, because send error
    // handling is not compatible
    stream.pipe(wrap)
  }

  function setUpHeadAndGet (routeOpts, route, file, rootPath) {
    const toSetUp = Object.assign({}, routeOpts, {
      method: ['HEAD', 'GET'],
      url: route,
      handler: serveFileHandler
    })
    toSetUp.config = toSetUp.config || {}
    toSetUp.config.file = file
    toSetUp.config.rootPath = rootPath
    fastify.route(toSetUp)
  }

  function serveFileHandler (req, reply) {
    // TODO: remove the fallback branch when bump major
    /* istanbul ignore next */
    const routeConfig = req.routeOptions?.config || req.routeConfig
    pumpSendToReply(req, reply, routeConfig.file, routeConfig.rootPath)
  }
}

function normalizeRoot (root) {
  if (root === undefined) {
    return root
  }
  if (root instanceof URL && root.protocol === 'file:') {
    return fileURLToPath(root)
  }
  if (Array.isArray(root)) {
    const result = []
    for (let i = 0, il = root.length; i < il; ++i) {
      if (root[i] instanceof URL && root[i].protocol === 'file:') {
        result.push(fileURLToPath(root[i]))
      } else {
        result.push(root[i])
      }
    }

    return result
  }

  return root
}

function checkRootPathForErrors (fastify, rootPath) {
  if (rootPath === undefined) {
    throw new Error('"root" option is required')
  }

  if (Array.isArray(rootPath)) {
    if (!rootPath.length) {
      throw new Error('"root" option array requires one or more paths')
    }

    if (new Set(rootPath).size !== rootPath.length) {
      throw new Error(
        '"root" option array contains one or more duplicate paths'
      )
    }

    // check each path and fail at first invalid
    rootPath.map((path) => checkPath(fastify, path))
    return
  }

  if (typeof rootPath === 'string') {
    return checkPath(fastify, rootPath)
  }

  throw new Error('"root" option must be a string or array of strings')
}

function checkPath (fastify, rootPath) {
  if (typeof rootPath !== 'string') {
    throw new Error('"root" option must be a string')
  }
  if (path.isAbsolute(rootPath) === false) {
    throw new Error('"root" option must be an absolute path')
  }

  let pathStat

  try {
    pathStat = statSync(rootPath)
  } catch (e) {
    if (e.code === 'ENOENT') {
      fastify.log.warn(`"root" path "${rootPath}" must exist`)
      return
    }

    throw e
  }

  if (pathStat.isDirectory() === false) {
    throw new Error('"root" option must point to a directory')
  }
}

function getContentType (path) {
  const type = send.mime.getType(path) || send.mime.default_type

  if (!send.isUtf8MimeType(type)) {
    return type
  }
  return `${type}; charset=UTF-8`
}

function findIndexFile (pathname, root, indexFiles = ['index.html']) {
  // TODO remove istanbul ignore
  /* istanbul ignore else */
  if (Array.isArray(indexFiles)) {
    return indexFiles.find(filename => {
      const p = path.join(root, pathname, filename)
      try {
        const stats = statSync(p)
        return !stats.isDirectory()
      } catch {
        return false
      }
    })
  }
  /* istanbul ignore next */
  return false
}

// Adapted from https://github.com/fastify/fastify-compress/blob/665e132fa63d3bf05ad37df3c20346660b71a857/index.js#L451
function getEncodingHeader (headers, checked) {
  if (!('accept-encoding' in headers)) return

  // consider the no-preference token as gzip for downstream compat
  const header = headers['accept-encoding'].toLowerCase().replace(asteriskRegex, 'gzip')

  return encodingNegotiator.negotiate(
    header,
    supportedEncodings.filter((enc) => !checked.has(enc))
  )
}

function getEncodingExtension (encoding) {
  switch (encoding) {
    case 'br':
      return 'br'

    case 'gzip':
      return 'gz'
  }
}

function getRedirectUrl (url) {
  let i = 0
  // we detect how many slash before a valid path
  for (; i < url.length; ++i) {
    if (url[i] !== '/' && url[i] !== '\\') break
  }
  // turns all leading / or \ into a single /
  url = '/' + url.substr(i)
  try {
    const parsed = new URL(url, 'http://localhost.com/')
    const parsedPathname = parsed.pathname
    return parsedPathname + (parsedPathname[parsedPathname.length - 1] !== '/' ? '/' : '') + (parsed.search || '')
  } catch {
    // the try-catch here is actually unreachable, but we keep it for safety and prevent DoS attack
    /* istanbul ignore next */
    const err = new Error(`Invalid redirect URL: ${url}`)
    /* istanbul ignore next */
    err.statusCode = 400
    /* istanbul ignore next */
    throw err
  }
}

module.exports = fp(fastifyStatic, {
  fastify: '4.x',
  name: '@fastify/static'
})
module.exports.default = fastifyStatic
module.exports.fastifyStatic = fastifyStatic
