'use strict'

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'
const encodingExtensionMap = {
  br: '.br',
  gzip: '.gz'
}

/** @type {import("fastify").FastifyPluginAsync<import("./types").FastifyStaticOptions>} */
async function fastifyStatic (fastify, opts) {
  if (opts.serve !== false || opts.root !== undefined) {
    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
  }

  opts.dotfiles ??= 'allow'

  const sendOptions = {
    root: opts.root,
    acceptRanges: opts.acceptRanges,
    contentType: opts.contentType,
    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 ??= '/'

  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 ?? true
    },
    logLevel: opts.logLevel,
    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?.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 TypeError('"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(getRedirectUrl(req.raw.url), 301)
        })
      }
    } else {
      const indexes = new Set(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, ignore: opts.globIgnore
        })

        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.has(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

  /**
   * @param {import("fastify").FastifyRequest} request
   * @param {import("fastify").FastifyReply} reply
   * @param {string} pathname
   * @param {import("./types").FastifyStaticOptions['root']} rootPath
   * @param {number} [rootPathOffset]
   * @param {import("@fastify/send").SendOptions} [pumpOptions]
   * @param {Set<string>} [checkedEncodings]
   */
  async 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
      }
    } else if (path.isAbsolute(pathname) === false) {
      return reply.callNotFound()
    }

    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
       */
      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 + encodingExtensionMap[encoding]
        } else {
          pathnameForSend = pathname + encodingExtensionMap[encoding]
        }
      }
    }

    // `send(..., path, ...)` will URI-decode path so we pass an encoded path here
    const {
      statusCode,
      headers,
      stream,
      type,
      metadata
    } = await send(request.raw, encodeURI(pathnameForSend), options)
    switch (type) {
      case 'directory': {
        const path = metadata.path
        if (opts.list) {
          await dirList.send({
            reply,
            dir: path,
            options: opts.list,
            route: pathname,
            prefix,
            dotfiles: opts.dotfiles
          }).catch((err) => reply.send(err))
        }

        if (opts.redirect === true) {
          try {
            reply.redirect(getRedirectUrl(request.raw.url), 301)
          } /* c8 ignore start */ catch (error) {
            // the try-catch here is actually unreachable, but we keep it for safety and prevent DoS attack
            await reply.send(error)
          } /* c8 ignore stop */
        } 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()
        }
        break
      }
      case 'error': {
        if (
          statusCode === 403 &&
          (!options.index || !options.index.length) &&
          pathnameForSend[pathnameForSend.length - 1] === '/'
        ) {
          if (opts.list) {
            await dirList.send({
              reply,
              dir: dirList.path(opts.root, pathname),
              options: opts.list,
              route: pathname,
              prefix,
              dotfiles: opts.dotfiles
            }).catch((err) => reply.send(err))
            return
          }
        }

        if (metadata.error.code === 'ENOENT') {
        // when preCompress is enabled and the path is a directory without a trailing slash
          if (opts.preCompressed && encoding) {
            if (opts.redirect !== true) {
              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)) {
            await 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 (metadata.error.status === 404) {
          return reply.callNotFound()
        }

        await reply.send(metadata.error)
        break
      }
      case 'file': {
        // reply.raw.statusCode by default 200
        // when ever the user changed it, we respect the status code
        // otherwise use send provided status code
        const newStatusCode = reply.statusCode !== 200 ? reply.statusCode : statusCode
        reply.code(newStatusCode)
        setHeaders?.(reply.raw, metadata.path, metadata.stat)
        reply.headers(headers)
        if (encoding) {
          reply.header('content-type', getContentType(pathname))
          reply.header('content-encoding', encoding)
        }
        await reply.send(stream)
        break
      }
    }
  }

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

  /** @type {import("fastify").RouteHandlerMethod} */
  async function serveFileHandler (req, reply) {
    const routeConfig = req.routeOptions?.config
    return pumpSendToReply(req, reply, routeConfig.file, routeConfig.rootPath)
  }
}

/**
 * @param {import("./types").FastifyStaticOptions['root']} root
 * @returns {import("./types").FastifyStaticOptions['root']}
 */
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
}

/**
 * @param {import("fastify").FastifyInstance} fastify
 * @param {import("./types").FastifyStaticOptions['root']} rootPath
 * @returns {void}
 */
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')
}

/**
 * @param {import("fastify").FastifyInstance} fastify
 * @param {import("./types").FastifyStaticOptions['root']} rootPath
 * @returns {void}
 */
function checkPath (fastify, rootPath) {
  if (typeof rootPath !== 'string') {
    throw new TypeError('"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')
  }
}

/**
 * @param {string} path
 * @return {string}
 */
function getContentType (path) {
  const type = send.mime.getType(path) || send.mime.default_type

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

/**
 * @param {string} pathname
 * @param {*} root
 * @param {import("./types").FastifyStaticOptions['index']} [indexFiles]
 * @return {string|boolean}
 */
function findIndexFile (pathname, root, indexFiles = ['index.html']) {
  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
      }
    })
  }
  /* c8 ignore next */
  return false
}

/**
 * Adapted from https://github.com/fastify/fastify-compress/blob/665e132fa63d3bf05ad37df3c20346660b71a857/index.js#L451
 * @param {import('fastify').FastifyRequest['headers']} headers
 * @param {Set<string>} checked
 */
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))
  )
}

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

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