import fs from 'fs'
import path from 'path'
import { Readable } from 'stream'

import type { NextFileUploadOptions } from './index.js'

// Parameters for safe file name parsing.
const SAFE_FILE_NAME_REGEX = /[^\w-]/g
const MAX_EXTENSION_LENGTH = 3

// Parameters to generate unique temporary file names:
const TEMP_COUNTER_MAX = 65536
const TEMP_PREFIX = 'tmp'
let tempCounter = 0

/**
 * Logs message to console if options.debug option set to true.
 */
export const debugLog = (options: NextFileUploadOptions, msg: string) => {
  const opts = options || {}
  if (!opts.debug) return false
  console.log(`Express-file-upload: ${msg}`) // eslint-disable-line
  return true
}

/**
 * Generates unique temporary file name. e.g. tmp-5000-156788789789.
 */
export const getTempFilename = (prefix: string = TEMP_PREFIX) => {
  tempCounter = tempCounter >= TEMP_COUNTER_MAX ? 1 : tempCounter + 1
  return `${prefix}-${tempCounter}-${Date.now()}`
}

type FuncType = (...args: any[]) => any
export const isFunc = (value: any): value is FuncType => {
  return typeof value === 'function'
}

/**
 * Set errorFunc to the same value as successFunc for callback mode.
 */
type ErrorFunc = (resolve: () => void, reject: (err: Error) => void) => (err: Error) => void
const errorFunc: ErrorFunc = (resolve, reject) => (isFunc(reject) ? reject : resolve)

/**
 * Return a callback function for promise resole/reject args.
 * Ensures that callback is called only once.
 */
type PromiseCallback = (resolve: () => void, reject: (err: Error) => void) => (err: Error) => void
export const promiseCallback: PromiseCallback = (resolve, reject) => {
  let hasFired = false
  return (err: Error) => {
    if (hasFired) {
      return
    }

    hasFired = true
    return err ? errorFunc(resolve, reject)(err) : resolve()
  }
}

// The default prototypes for both objects and arrays.
// Used by isSafeFromPollution
const OBJECT_PROTOTYPE_KEYS = Object.getOwnPropertyNames(Object.prototype)
const ARRAY_PROTOTYPE_KEYS = Object.getOwnPropertyNames(Array.prototype)

/**
 * Determines whether a key insertion into an object could result in a prototype pollution
 */
type IsSafeFromPollution = (base: any, key: string) => boolean
export const isSafeFromPollution: IsSafeFromPollution = (base, key) => {
  // We perform an instanceof check instead of Array.isArray as the former is more
  // permissive for cases in which the object as an Array prototype but was not constructed
  // via an Array constructor or literal.
  const TOUCHES_ARRAY_PROTOTYPE = base instanceof Array && ARRAY_PROTOTYPE_KEYS.includes(key)
  const TOUCHES_OBJECT_PROTOTYPE = OBJECT_PROTOTYPE_KEYS.includes(key)

  return !TOUCHES_ARRAY_PROTOTYPE && !TOUCHES_OBJECT_PROTOTYPE
}

/**
 * Build request field/file objects to return
 */
type BuildFields = (instance: any, field: string, value: any) => any
export const buildFields: BuildFields = (instance, field, value) => {
  // Do nothing if value is not set.
  if (value === null || value === undefined) return instance
  instance = instance || Object.create(null)

  if (!isSafeFromPollution(instance, field)) {
    return instance
  }
  // Non-array fields
  if (!instance[field]) {
    instance[field] = value
    return instance
  }
  // Array fields
  if (instance[field] instanceof Array) {
    instance[field].push(value)
  } else {
    instance[field] = [instance[field], value]
  }
  return instance
}

/**
 * Creates a folder if it does not exist
 * for file specified in the path variable
 */
type CheckAndMakeDir = (fileUploadOptions: NextFileUploadOptions, filePath: string) => boolean
export const checkAndMakeDir: CheckAndMakeDir = (fileUploadOptions, filePath) => {
  if (!fileUploadOptions.createParentPath) return false
  // Check whether folder for the file exists.
  const parentPath = path.dirname(filePath)
  // Create folder if it doesn't exist.
  if (!fs.existsSync(parentPath)) fs.mkdirSync(parentPath, { recursive: true })
  // Checks folder again and return a results.
  return fs.existsSync(parentPath)
}

/**
 * Delete a file.
 */
type DeleteFile = (filePath: string, callback: (args: any) => void) => void
export const deleteFile: DeleteFile = (filePath, callback: (args) => void) =>
  fs.unlink(filePath, callback)

/**
 * Copy file via streams
 */
type CopyFile = (src: string, dst: string, callback: (err: Error) => void) => void
const copyFile: CopyFile = (src, dst, callback) => {
  // cbCalled flag and runCb helps to run cb only once.
  let cbCalled = false
  const runCb = (err?: Error) => {
    if (cbCalled) return
    cbCalled = true
    callback(err)
  }
  // Create read stream
  const readable = fs.createReadStream(src)
  readable.on('error', runCb)
  // Create write stream
  const writable = fs.createWriteStream(dst)
  writable.on('error', (err: Error) => {
    readable.destroy()
    runCb(err)
  })
  writable.on('close', () => runCb())
  // Copy file via piping streams.
  readable.pipe(writable)
}

/**
 * moveFile: moves the file from src to dst.
 * Firstly trying to rename the file if no luck copying it to dst and then deleting src.
 */
type MoveFile = (
  src: string,
  dst: string,
  callback: (err: Error, renamed?: boolean) => void,
) => void
export const moveFile: MoveFile = (src, dst, callback) =>
  fs.rename(src, dst, (err) => {
    if (err) {
      // Try to copy file if rename didn't work.
      copyFile(src, dst, (cpErr) => (cpErr ? callback(cpErr) : deleteFile(src, callback)))
      return
    }
    // File was renamed successfully: Add true to the callback to indicate that.
    callback(null, true)
  })

/**
 * Save buffer data to a file.
 * @param {Buffer} buffer - buffer to save to a file.
 * @param {string} filePath - path to a file.
 */
export const saveBufferToFile = (buffer, filePath, callback) => {
  if (!Buffer.isBuffer(buffer)) {
    return callback(new Error('buffer variable should be type of Buffer!'))
  }
  // Setup readable stream from buffer.
  let streamData = buffer
  const readStream = new Readable()
  readStream._read = () => {
    readStream.push(streamData)
    streamData = null
  }
  // Setup file system writable stream.
  const fstream = fs.createWriteStream(filePath)
  // console.log("Calling saveBuffer");
  fstream.on('error', (err) => {
    // console.log("err cb")
    callback(err)
  })
  fstream.on('close', () => {
    // console.log("close cb");
    callback()
  })
  // Copy file via piping streams.
  readStream.pipe(fstream)
}

/**
 * Decodes uriEncoded file names.
 * @param {Object} opts - middleware options.
 * @param fileName {String} - file name to decode.
 * @returns {String}
 */
const uriDecodeFileName = (opts, fileName) => {
  if (!opts || !opts.uriDecodeFileNames) {
    return fileName
  }
  // Decode file name from URI with checking URI malformed errors.
  // See Issue https://github.com/richardgirges/express-fileupload/issues/342.
  try {
    return decodeURIComponent(fileName)
  } catch (err) {
    const matcher = /(%[a-f\d]{2})/gi
    return fileName
      .split(matcher)
      .map((str) => {
        try {
          return decodeURIComponent(str)
        } catch (err) {
          return ''
        }
      })
      .join('')
  }
}

/**
 * Parses filename and extension and returns object {name, extension}.
 */
type ParseFileNameExtension = (
  preserveExtension: boolean | number,
  fileName: string,
) => {
  extension: string
  name: string
}
export const parseFileNameExtension: ParseFileNameExtension = (preserveExtension, fileName) => {
  const defaultResult = {
    name: fileName,
    extension: '',
  }
  if (!preserveExtension) return defaultResult

  // Define maximum extension length
  const maxExtLength =
    typeof preserveExtension === 'boolean' ? MAX_EXTENSION_LENGTH : preserveExtension

  const nameParts = fileName.split('.')
  if (nameParts.length < 2) return defaultResult

  let extension = nameParts.pop()
  if (extension.length > maxExtLength && maxExtLength > 0) {
    nameParts[nameParts.length - 1] += '.' + extension.substr(0, extension.length - maxExtLength)
    extension = extension.substr(-maxExtLength)
  }

  return {
    name: nameParts.join('.'),
    extension: maxExtLength ? extension : '',
  }
}

/**
 * Parse file name and extension.
 */
type ParseFileName = (opts: NextFileUploadOptions, fileName: string) => string
export const parseFileName: ParseFileName = (opts, fileName) => {
  // Check fileName argument
  if (!fileName || typeof fileName !== 'string') return getTempFilename()
  // Cut off file name if it's length more then 255.
  let parsedName = fileName.length <= 255 ? fileName : fileName.substr(0, 255)
  // Decode file name if uriDecodeFileNames option set true.
  parsedName = uriDecodeFileName(opts, parsedName)
  // Stop parsing file name if safeFileNames options hasn't been set.
  if (!opts.safeFileNames) return parsedName
  // Set regular expression for the file name.
  const nameRegex =
    typeof opts.safeFileNames === 'object' && opts.safeFileNames instanceof RegExp
      ? opts.safeFileNames
      : SAFE_FILE_NAME_REGEX
  // Parse file name extension.
  let { name, extension } = parseFileNameExtension(opts.preserveExtension, parsedName)
  if (extension.length) extension = '.' + extension.replace(nameRegex, '')

  return name.replace(nameRegex, '').concat(extension)
}
