/* global nhashFile */
const path = require('path')
const mime = require('@unitybase/mime-types')
const fs = require('fs')

const FN_VALIDATION_RE = /^[\w\-. ]+$/

/**
 * @classdesc
 * Abstract interface for Virtual store. Must be implemented in descendants.
 * Provide a way to store files in any manner developer want.
 */

/* BlobStoreItem sample:
{"store":"documents","fName":"contr_contractdoc document 3000000405832.pdf",
  "origName":"Contract #01T.pdf",
  "relPath":"435\\",
  "ct":"application/pdf",
  "size":2057405,
  "md5":"3b44f38f6b120615604846b67150fcb0",
  "revision":2}
*/
class BlobStoreCustom {
  /**
   * @param {Object} storeConfig
   * @param {ServerApp} appInstance
   * @param {UBSession} sessionInstance
   * @param {object} [options]
   */
  constructor (storeConfig, appInstance, sessionInstance, options) {
    /** @type {ServerApp} */
    this.App = appInstance
    /** @type {UBSession} */
    this.Session = sessionInstance
    this.PROXY_SEND_FILE_HEADER = this.App.serverConfig.httpServer.reverseProxy.sendFileHeader
    this.PROXY_SEND_FILE_LOCATION_ROOT = this.App.serverConfig.httpServer.reverseProxy.sendFileLocationRoot
    /**
     * Store parameters as defined in ubConfig
     */
    this.config = Object.assign({}, storeConfig)
    /**
     * Name of store (from app config)
     */
    this.name = this.config.name
    /**
     * Path to temp folder
     * @type {string}
     * @protected
     */
    this.tempFolder = this.config.tempPath
    /**
     * How many previous revision is stored
     * @type {number}
     */
    this.historyDepth = this.config.historyDepth || 0
    /**
     * Set of allowed extensions (Example: '.jpg', '.png'). If null - any extension is allowed.
     *
     * @type {Set<string>|null}
     */
    this.whileListExtensionSet = this.config.whitelistExtensions && this.config.whitelistExtensions.length
      ? new Set(this.config.whitelistExtensions)
      : null
    /**
     * Set of prohibited extensions (Example: '.jpg', '.png')
     *
     * @type {Set<string>}
     */
    this.blacklistExtensionsSet = this.config.blacklistExtensions && this.config.blacklistExtensions.length
      ? new Set(this.config.blacklistExtensions)
      : new Set()

    /**
     * Enable access audit for external requests for this BLOB store class
     * @type {boolean}
     */
    this.accessAudit = this.App.domainInfo.has('uba_auditTrail') && (this.App.serverConfig.security.blobStoresAccessAudit === true)
    /**
     * Enable validation what uploaded PDF files do not contain JavaScript code
     * @type {boolean}
     */
    this.checkForMaliciousPDF = this.config.checkForMaliciousPDF === true
  }

  /**
   * Save file content to temporary store with chunked upload support
   *
   * @abstract
   * @param {BlobStoreRequest} request Request params
   * @param {UBEntityAttribute} attribute
   * @param {ArrayBuffer|THTTPRequest} content
   * @param {object} [options]
   * @param {boolean} options.contentIsFilePath
   * @returns {BlobStoreItem}
   */
  saveContentToTempStore (request, attribute, content, options = {}) {
    const fn = this.getTempFileName(request)
    const { chunksTotal, chunkNum, chunkSize } = request
    let fileSize = 0

    if (chunksTotal > 1 && chunksTotal !== chunkNum + 1) {
      console.debug(`temp file's chunk (${chunkNum + 1} of ${chunksTotal}) will be written to`, fn)
    } else {
      console.debug('temp file will be written to', fn)
    }

    try {
      let isFileExists = fs.existsSync(fn)
      let isChunksValidErr = false

      // Check exists, incomplete file state
      if (isFileExists) {
        if (chunkNum === 0) {
          fs.unlinkSync(fn)
          isFileExists = false
        }
        if (chunkNum > 0 && fs.statSync(fn).size !== chunkNum * chunkSize) isChunksValidErr = true
      } else {
        if (chunkNum > 0) isChunksValidErr = true
      }
      if (isChunksValidErr) {
        throw new Error(`Chunks size validation error when uploading the file "${request.fileName}"`)
      }

      // Write file content
      if (isFileExists) {
        if (content.appendToFile) {
          if (!content.appendToFile(fn)) throw new Error(`Error append to ${fn}`)
        } else {
          fs.appendFileSync(fn, content)
        }
      } else {
        if (content.writeToFile) {
          if (!content.writeToFile(fn)) throw new Error(`Error write to ${fn}`)
        } else {
          if (options && options.contentIsFilePath) {
            global.copyFile(content, fn, true)
          } else {
            fs.writeFileSync(fn, content)
          }
        }
      }

      if (chunksTotal !== chunkNum + 1) return null

      fileSize = fs.statSync(fn).size
    } catch (e) {
      if (fs.existsSync(fn)) fs.unlinkSync(fn)
      throw e
    }

    const origFn = request.fileName || 'no-file-name.bin'
    const ct = this.getMimeType(origFn)
    if (this.checkForMaliciousPDF && (ct === 'application/pdf')) {
      const buf = fs.readFileSync(fn)
      const isBad = checkPdfHasEmbeddedJs(buf)
      if (isBad) {
        fs.unlinkSync(fn)
        throw new Error('<<<attemptToUploadBadPDF>>>')
      }
    }
    const newMD5 = nhashFile(fn, 'MD5')

    return {
      store: this.name,
      fName: origFn,
      origName: origFn,
      ct,
      size: fileSize,
      md5: newMD5,
      isDirty: true
    }
  }

  /**
   * Returns full path to the file with BLOB content
   * @param {BlobStoreRequest} request
   * @param {BlobStoreItem} blobInfo JSON retrieved from a DB
   * @returns {string}
   */
  getContentFilePath (request, blobInfo) {
    return ''
  }

  /**
   * Retrieve BLOB content from blob store.
   * @abstract
   * @param {BlobStoreRequest} request
   * @param {BlobStoreItem} blobInfo JSON retrieved from a DB
   * @param {object} [options]
   * @param {string|null} [options.encoding] Possible values:
   *   'bin' 'ascii' 'binary' 'hex' ucs2/ucs-2/utf16le/utf-16le utf8/utf-8
   *   if `null` will return {@link Buffer}, if `bin` - ArrayBuffer
   * @returns {string|Buffer|ArrayBuffer|null}
   */
  getContent (request, blobInfo, options) {}

  /**
   * Fill HTTP response for getDocument request. Sets resp to 404 status if content not found.
   * @abstract
   * @param {BlobStoreRequest} requestParams
   * @param {BlobStoreItem} blobInfo
   * @param {THTTPRequest} req
   * @param {THTTPResponse} resp
   * @param {boolean} [preventChangeRespOnError=false] If `true` - prevents sets resp status code - just returns false on error
   * @returns {boolean}
   */
  fillResponse (requestParams, blobInfo, req, resp, preventChangeRespOnError) { }
  /**
   * Move content defined by `dirtyItem` from temporary to permanent store.
   * Return a new attribute content which describe a place of BLOB in permanent store
   * @abstract
   * @param {UBEntityAttribute} attribute
   * @param {number} ID
   * @param {BlobStoreItem} dirtyItem
   * @param {number} newRevision
   * @return {BlobStoreItem|null}
   */
  persist (attribute, ID, dirtyItem, newRevision) { }

  /**
   * Do something with BLOB content during archiving. For example - move to slow drive etc.
   * Default implementation do nothing.
   * @param {UBEntityAttribute} attribute
   * @param {number} ID
   * @param {BlobStoreItem} blobInfo
   * @returns {BlobStoreItem}
   */
  doArchive (attribute, ID, blobInfo) {
    return blobInfo
  }

  /**
   * Delete persisted BLOB content
   * @abstract
   * @param {UBEntityAttribute} attribute
   * @param {number} ID
   * @param {BlobStoreItem} blobInfo
   */
  doDeletion (attribute, ID, blobInfo) { }
  /**
   * Get path to temporary file and it's name
   * @protected
   * @param {BlobStoreRequest} request
   * @returns {string}
   */
  getTempFileName (request) {
    // important to use Session.userID. See UB-617
    return path.join(this.tempFolder, `${request.entity}_${request.attribute}_${request.ID}_${this.Session.userID}`)
  }

  /**
   * Check file extension allowed by store whileList and not prohibited dy store blackList
   *
   * @param {string} ext
   * @returns {boolean}
   */
  extensionAllowed (ext) {
    const el = ext.toLowerCase()
    return this.whileListExtensionSet
      ? this.whileListExtensionSet.has(el) && !this.blacklistExtensionsSet.has(el)
      : !this.blacklistExtensionsSet.has(el)
  }

  /**
   * validate file name contains only alphanumeric characters, -, _, . and space and not contains ..
   * @param {string} fn
   * @throws throws in file name is not valid
   */
  static validateFileName (fn) {
    if (!FN_VALIDATION_RE.test(fn) || (fn.indexOf('..') !== -1)) {
      const e = new Error(`Invalid file name '${fn}' for BLOB store`)
      // emulate a ESecurityException
      // eslint-disable-next-line n/no-deprecated-api
      e.errorNumber = process.binding('ub_app').UBEXC_ESECURITY_EXCEPTION
      throw e
    }
  }

  /**
   * Get mime type for passed file name or file extension. Recognize well known extensions missed in mime-db@1.52
   * @param {string} fileNameOrExt
   * @param {boolean} [isExtension=false] is first parameter a file extension (`.pdf` for example)
   * @returns {string}
   */
  getMimeType (fileNameOrExt, isExtension = false) {
    const ext = isExtension ? fileNameOrExt : path.extname(fileNameOrExt)
    return mime.contentType(ext)
  }
}

/**
 * Returns `true` in case PDF (loaded as buffer) contains embedded JavaScript
 *
 * @param {Buffer} buf
 * @return {boolean}
 */
function checkPdfHasEmbeddedJs (buf) {
  let p
  let fromIndex = 0
  const L = buf.length
  // search for `\/JS\s+\(` pattern
  do {
    p = buf.indexOf('/JS', fromIndex)
    if (p === -1) return false // no JavaScript
    p += 3 // move to end of /JS tag
    if (p > L) return false // not a script because of EEF
    let v
    do {
      v = buf.readUInt8(p)
      p++
    } while ((p < L) && (v <= 32)) // skip all non-char
    if (p >= L) return false // EOF
    if (v === 40) return true // `(` sign
    fromIndex = p
  } while (p !== -1)
  return false
}

module.exports = BlobStoreCustom