
/*
CSV Stringify

Please look at the [project documentation](https://csv.js.org/stringify/) for
additional information.
*/

const { Transform } = require('stream')
const bom_utf8 = Buffer.from([239, 187, 191])

class Stringifier extends Transform {
  constructor(opts = {}){
    super({...{writableObjectMode: true}, ...opts})
    const options = {}
    let err
    // Merge with user options
    for(let opt in opts){
      options[underscore(opt)] = opts[opt]
    }
    if(err = this.normalize(options)) throw err
    switch(options.record_delimiter){
      case 'auto':
        options.record_delimiter = null
        break
      case 'unix':
        options.record_delimiter = "\n"
        break
      case 'mac':
        options.record_delimiter = "\r"
        break
      case 'windows':
        options.record_delimiter = "\r\n"
        break
      case 'ascii':
        options.record_delimiter = "\u001e"
        break
      case 'unicode':
        options.record_delimiter = "\u2028"
        break
    }
    // Expose options
    this.options = options
    // Internal state
    this.state = {
      stop: false
    }
    // Information
    this.info = {
      records: 0
    }
  }
  normalize(options){
    // Normalize option `bom`
    if(options.bom === undefined || options.bom === null || options.bom === false){
      options.bom = false
    }else if(options.bom !== true){
      return new CsvError('CSV_OPTION_BOOLEAN_INVALID_TYPE', [
        'option `bom` is optional and must be a boolean value,',
        `got ${JSON.stringify(options.bom)}`
      ])
    }
    // Normalize option `delimiter`
    if(options.delimiter === undefined || options.delimiter === null){
      options.delimiter = ','
    }else if(Buffer.isBuffer(options.delimiter)){
      options.delimiter = options.delimiter.toString()
    }else if(typeof options.delimiter !== 'string'){
      return new CsvError('CSV_OPTION_DELIMITER_INVALID_TYPE', [
        'option `delimiter` must be a buffer or a string,',
        `got ${JSON.stringify(options.delimiter)}`
      ])
    }
    // Normalize option `quote`
    if(options.quote === undefined || options.quote === null){
      options.quote = '"'
    }else if(options.quote === true){
      options.quote = '"'
    }else if(options.quote === false){
      options.quote = ''
    }else if (Buffer.isBuffer(options.quote)){
      options.quote = options.quote.toString()
    }else if(typeof options.quote !== 'string'){
      return new CsvError('CSV_OPTION_QUOTE_INVALID_TYPE', [
        'option `quote` must be a boolean, a buffer or a string,',
        `got ${JSON.stringify(options.quote)}`
      ])
    }
    // Normalize option `quoted`
    if(options.quoted === undefined || options.quoted === null){
      options.quoted = false
    }else{
      // todo
    }
    // Normalize option `quoted_empty`
    if(options.quoted_empty === undefined || options.quoted_empty === null){
      options.quoted_empty = undefined
    }else{
      // todo
    }
    // Normalize option `quoted_match`
    if(options.quoted_match === undefined || options.quoted_match === null || options.quoted_match === false){
      options.quoted_match = null
    }else if(!Array.isArray(options.quoted_match)){
      options.quoted_match = [options.quoted_match]
    }
    if(options.quoted_match){
      for(let quoted_match of options.quoted_match){
        const isString = typeof quoted_match === 'string'
        const isRegExp = quoted_match instanceof RegExp
        if(!isString && !isRegExp){
          return Error(`Invalid Option: quoted_match must be a string or a regex, got ${JSON.stringify(quoted_match)}`)
        }
      }
    }
    // Normalize option `quoted_string`
    if(options.quoted_string === undefined || options.quoted_string === null){
      options.quoted_string = false
    }else{
      // todo
    }
    // Normalize option `eof`
    if(options.eof === undefined || options.eof === null){
      options.eof = true
    }else{
      // todo
    }
    // Normalize option `escape`
    if(options.escape === undefined || options.escape === null){
      options.escape = '"'
    }else if(Buffer.isBuffer(options.escape)){
      options.escape = options.escape.toString()
    }else if(typeof options.escape !== 'string'){
      return Error(`Invalid Option: escape must be a buffer or a string, got ${JSON.stringify(options.escape)}`)
    }
    if (options.escape.length > 1){
      return Error(`Invalid Option: escape must be one character, got ${options.escape.length} characters`)
    }
    // Normalize option `header`
    if(options.header === undefined || options.header === null){
      options.header = false
    }else{
      // todo
    }
    // Normalize option `columns`
    options.columns = this.normalize_columns(options.columns)
    // Normalize option `quoted`
    if(options.quoted === undefined || options.quoted === null){
      options.quoted = false
    }else{
      // todo
    }
    // Normalize option `cast`
    if(options.cast === undefined || options.cast === null){
      options.cast = {}
    }else{
      // todo
    }
    // Normalize option cast.bigint
    if(options.cast.bigint === undefined || options.cast.bigint === null){
      // Cast boolean to string by default
      options.cast.bigint = value => '' + value
    }
    // Normalize option cast.boolean
    if(options.cast.boolean === undefined || options.cast.boolean === null){
      // Cast boolean to string by default
      options.cast.boolean = value => value ? '1' : ''
    }
    // Normalize option cast.date
    if(options.cast.date === undefined || options.cast.date === null){
      // Cast date to timestamp string by default
      options.cast.date = value => '' + value.getTime()
    }
    // Normalize option cast.number
    if(options.cast.number === undefined || options.cast.number === null){
      // Cast number to string using native casting by default
      options.cast.number = value => '' + value
    }
    // Normalize option cast.object
    if(options.cast.object === undefined || options.cast.object === null){
      // Stringify object as JSON by default
      options.cast.object = value => JSON.stringify(value)
    }
    // Normalize option cast.string
    if(options.cast.string === undefined || options.cast.string === null){
      // Leave string untouched
      options.cast.string = function(value){return value}
    }
    // Normalize option `record_delimiter`
    if(options.record_delimiter === undefined || options.record_delimiter === null){
      options.record_delimiter = '\n'
    }else if(Buffer.isBuffer(options.record_delimiter)){
      options.record_delimiter = options.record_delimiter.toString()
    }else if(typeof options.record_delimiter !== 'string'){
      return Error(`Invalid Option: record_delimiter must be a buffer or a string, got ${JSON.stringify(options.record_delimiter)}`)
    }
  }
  _transform(chunk, encoding, callback){
    if(this.state.stop === true){
      return
    }
    // Chunk validation
    if(!Array.isArray(chunk) && typeof chunk !== 'object'){
      this.state.stop = true
      return callback(Error(`Invalid Record: expect an array or an object, got ${JSON.stringify(chunk)}`))
    }
    // Detect columns from the first record
    if(this.info.records === 0){
      if(Array.isArray(chunk)){
        if(this.options.header === true && !this.options.columns){
          this.state.stop = true
          return callback(Error('Undiscoverable Columns: header option requires column option or object records'))
        }
      }else if(this.options.columns === undefined || this.options.columns === null){
        this.options.columns = this.normalize_columns(Object.keys(chunk))
      }
    }
    // Emit the header
    if(this.info.records === 0){
      this.bom()
      this.headers()
    }
    // Emit and stringify the record if an object or an array
    try{
      this.emit('record', chunk, this.info.records)
    }catch(err){
      this.state.stop = true
      return this.emit('error', err)
    }
    // Convert the record into a string
    let chunk_string
    if(this.options.eof){
      chunk_string = this.stringify(chunk)
      if(chunk_string === undefined){
        return
      }else{
        chunk_string = chunk_string + this.options.record_delimiter
      }
    }else{
      chunk_string = this.stringify(chunk)
      if(chunk_string === undefined){
        return
      }else{
        if(this.options.header || this.info.records){
          chunk_string = this.options.record_delimiter + chunk_string
        }
      }
    }
    // Emit the csv
    this.info.records++
    this.push(chunk_string)
    callback()
  }
  _flush(callback){
    if(this.info.records === 0){
      this.bom()
      this.headers()
    }
    callback()
  }
  stringify(chunk, chunkIsHeader=false){
    if(typeof chunk !== 'object'){
      return chunk
    }
    const {columns, header} = this.options
    const record = []
    // Record is an array
    if(Array.isArray(chunk)){
      // We are getting an array but the user has specified output columns. In
      // this case, we respect the columns indexes
      if(columns){
        chunk.splice(columns.length)
      }
      // Cast record elements
      for(let i=0; i<chunk.length; i++){
        const field = chunk[i]
        const [err, value] = this.__cast(field, {
          index: i, column: i, records: this.info.records, header: chunkIsHeader
        })
        if(err){
          this.emit('error', err)
          return
        }
        record[i] = [value, field]
      }
    // Record is a literal object
    }else{
      if(columns){
        for(let i=0; i<columns.length; i++){
          const field = get(chunk, columns[i].key)
          const [err, value] = this.__cast(field, {
            index: i, column: columns[i].key, records: this.info.records, header: chunkIsHeader
          })
          if(err){
            this.emit('error', err)
            return
          }
          record[i] = [value, field]
        }
      }else{
        for(let column of chunk){
          const field = chunk[column]
          const [err, value] = this.__cast(field, {
            index: i, column: columns[i].key, records: this.info.records, header: chunkIsHeader
          })
          if(err){
            this.emit('error', err)
            return
          }
          record.push([value, field])
        }
      }
    }
    let csvrecord = ''
    for(let i=0; i<record.length; i++){
      let options, err
      let [value, field] = record[i]
      if(typeof value === "string"){
        options = this.options
      }else if(isObject(value)){
        // let { value, ...options } = value
        options = value
        value = options.value
        delete options.value
        if(typeof value !== "string" && value !== undefined && value !== null){
          this.emit("error", Error(`Invalid Casting Value: returned value must return a string, null or undefined, got ${JSON.stringify(value)}`))
          return
        }
        options = {...this.options, ...options}
        if(err = this.normalize(options)){
          this.emit("error", err)
          return
        }
      }else if(value === undefined || value === null){
        options = this.options
      }else{
        this.emit("error", Error(`Invalid Casting Value: returned value must return a string, an object, null or undefined, got ${JSON.stringify(value)}`))
        return
      }
      const {delimiter, escape, quote, quoted, quoted_empty, quoted_string, quoted_match, record_delimiter} = options
      if(value){
        if(typeof value !== 'string'){
          this.emit("error", Error(`Formatter must return a string, null or undefined, got ${JSON.stringify(value)}`))
          return null
        }
        const containsdelimiter = delimiter.length && value.indexOf(delimiter) >= 0
        const containsQuote = (quote !== '') && value.indexOf(quote) >= 0
        const containsEscape = value.indexOf(escape) >= 0 && (escape !== quote)
        const containsRecordDelimiter = value.indexOf(record_delimiter) >= 0
        const quotedString = quoted_string && typeof field === 'string'
        let quotedMatch = quoted_match && quoted_match.filter( quoted_match => {
          if(typeof quoted_match === 'string'){
            return value.indexOf(quoted_match) !== -1
          }else{
            return quoted_match.test(value)
          }
        })
        quotedMatch = quotedMatch && quotedMatch.length > 0
        const shouldQuote = containsQuote === true || containsdelimiter || containsRecordDelimiter || quoted || quotedString || quotedMatch
        if(shouldQuote === true && containsEscape === true){
          const regexp = escape === '\\'
          ? new RegExp(escape + escape, 'g')
          : new RegExp(escape, 'g')
          value = value.replace(regexp, escape + escape)
        }
        if(containsQuote === true){
          const regexp = new RegExp(quote,'g')
          value = value.replace(regexp, escape + quote)
        }
        if(shouldQuote === true){
          value = quote + value + quote
        }
        csvrecord += value
      }else if(quoted_empty === true || (field === '' && quoted_string === true && quoted_empty !== false)){
        csvrecord += quote + quote
      }
      if(i !== record.length - 1){
        csvrecord += delimiter
      }
    }
    return csvrecord
  }
  bom(){
    if(this.options.bom !== true){
      return
    }
    this.push(bom_utf8)
  }
  headers(){
    if(this.options.header === false){
      return
    }
    if(this.options.columns === undefined){
      return
    }
    let headers = this.options.columns.map(column => column.header)
    if(this.options.eof){
      headers = this.stringify(headers, true) + this.options.record_delimiter
    }else{
      headers = this.stringify(headers)
    }
    this.push(headers)
  }
  __cast(value, context){
    const type = typeof value
    try{
      if(type === 'string'){ // Fine for 99% of the cases
        return [undefined, this.options.cast.string(value, context)]
      }else if(type === 'bigint'){
        return [undefined, this.options.cast.bigint(value, context)]
      }else if(type === 'number'){
        return [undefined, this.options.cast.number(value, context)]
      }else if(type === 'boolean'){
        return [undefined, this.options.cast.boolean(value, context)]
      }else if(value instanceof Date){
        return [undefined, this.options.cast.date(value, context)]
      }else if(type === 'object' && value !== null){
        return [undefined, this.options.cast.object(value, context)]
      }else{
        return [undefined, value, value]
      }
    }catch(err){
      return [err]
    }
  }
  normalize_columns(columns){
    if(columns === undefined || columns === null){
      return undefined
    }
    if(typeof columns !== 'object'){
      throw Error('Invalid option "columns": expect an array or an object')
    }
    if(!Array.isArray(columns)){
      const newcolumns = []
      for(let k in columns){
        newcolumns.push({
          key: k,
          header: columns[k]
        })
      }
      columns = newcolumns
    }else{
      const newcolumns = []
      for(let column of columns){
        if(typeof column === 'string'){
          newcolumns.push({
            key: column,
            header: column
          })
        }else if(typeof column === 'object' && column !== undefined && !Array.isArray(column)){
          if(!column.key){
            throw Error('Invalid column definition: property "key" is required')
          }
          if(column.header === undefined){
            column.header = column.key
          }
          newcolumns.push(column)
        }else{
          throw Error('Invalid column definition: expect a string or an object')
        }
      }
      columns = newcolumns
    }
    return columns
  }
}

const stringify = function(){
  let data, options, callback
  for(let i in arguments){
    const argument = arguments[i]
    const type = typeof argument
    if(data === undefined && (Array.isArray(argument))){
      data = argument
    }else if(options === undefined && isObject(argument)){
      options = argument
    }else if(callback === undefined && type === 'function'){
      callback = argument
    }else{
      throw new CsvError('CSV_INVALID_ARGUMENT', [
        'Invalid argument:',
        `got ${JSON.stringify(argument)} at index ${i}`
      ])
    }
  }
  const stringifier = new Stringifier(options)
  if(callback){
    const chunks = []
    stringifier.on('readable', function(){
      let chunk
      while((chunk = this.read()) !== null){
        chunks.push(chunk)
      }
    })
    stringifier.on('error', function(err){
      callback(err)
    })
    stringifier.on('end', function(){
      callback(undefined, chunks.join(''))
    })
  }
  if(data !== undefined){
    // Give a chance for events to be registered later
    if(typeof setImmediate === 'function'){
      setImmediate(function(){
        for(let record of data){
          stringifier.write(record)
        }
        stringifier.end()
      })
    }else{
      for(let record of data){
        stringifier.write(record)
      }
      stringifier.end()
    }
  }
  return stringifier
}

class CsvError extends Error {
  constructor(code, message, ...contexts) {
    if(Array.isArray(message)) message = message.join(' ')
    super(message)
    if(Error.captureStackTrace !== undefined){
      Error.captureStackTrace(this, CsvError)
    }
    this.code = code
    for(const context of contexts){
      for(const key in context){
        const value = context[key]
        this[key] = Buffer.isBuffer(value) ? value.toString() : value == null ? value : JSON.parse(JSON.stringify(value))
      }
    }
  }
}

stringify.Stringifier = Stringifier

stringify.CsvError = CsvError

module.exports = stringify

const isObject = function(obj){
  return typeof obj === 'object' && obj !== null && ! Array.isArray(obj)
}

const underscore = function(str){
  return str.replace(/([A-Z])/g, function(_, match){
    return '_' + match.toLowerCase()
  })
}

// Lodash implementation of `get`

const charCodeOfDot = '.'.charCodeAt(0)
const reEscapeChar = /\\(\\)?/g
const rePropName = RegExp(
  // Match anything that isn't a dot or bracket.
  '[^.[\\]]+' + '|' +
  // Or match property names within brackets.
  '\\[(?:' +
    // Match a non-string expression.
    '([^"\'][^[]*)' + '|' +
    // Or match strings (supports escaping characters).
    '(["\'])((?:(?!\\2)[^\\\\]|\\\\.)*?)\\2' +
  ')\\]'+ '|' +
  // Or match "" as the space between consecutive dots or empty brackets.
  '(?=(?:\\.|\\[\\])(?:\\.|\\[\\]|$))'
, 'g')
const reIsDeepProp = /\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/
const reIsPlainProp = /^\w*$/
const getTag = function(value){
  if(!value)
    value === undefined ? '[object Undefined]' : '[object Null]'
  return Object.prototype.toString.call(value)
}
const isKey = function(value, object){
  if(Array.isArray(value)){
    return false
  }
  const type = typeof value
  if(type === 'number' || type === 'symbol' || type === 'boolean' || !value || isSymbol(value)){
    return true
  }
  return reIsPlainProp.test(value) || !reIsDeepProp.test(value) ||
    (object != null && value in Object(object))
}
const isSymbol = function(value){
  const type = typeof value
  return type === 'symbol' || (type === 'object' && value && getTag(value) === '[object Symbol]')
}
const stringToPath = function(string){
  const result = []
  if(string.charCodeAt(0) === charCodeOfDot){
    result.push('')
  }
  string.replace(rePropName, function(match, expression, quote, subString){
    let key = match
    if(quote){
      key = subString.replace(reEscapeChar, '$1')
    }else if(expression){
      key = expression.trim()
    }
    result.push(key)
  })
  return result
}
const castPath = function(value, object){
  if(Array.isArray(value)){
    return value
  } else {
    return isKey(value, object) ? [value] : stringToPath(value)
  }
}
const toKey = function(value){
  if(typeof value === 'string' || isSymbol(value))
    return value
  const result = `${value}`
  return (result == '0' && (1 / value) == -INFINITY) ? '-0' : result
}
const get = function(object, path){
  path = castPath(path, object)
  let index = 0
  const length = path.length
  while(object != null && index < length){
    object = object[toKey(path[index++])]
  }
  return (index && index === length) ? object : undefined
}
