/**
 * Helper for manipulation with data, stored locally in ({@link TubCachedData} format).
 *
 * This module shared between client & server. In case of server we use it together with {@link dataLoader},
 * in case of client - inside {@link class:SyncConnection#select SyncConnection.select} to handle operations with entity data cached in IndexedDB.
 *
 * For server-side samples see ubm_forms.doSelect method implementation.
 *
 * @example
$App.connection.run({
  entity: 'tst_IDMapping',
  method: 'addnew',
  fieldList: ['ID', 'code']
}).then(function(result){
  // here result in array-of-array format
  // [{"entity":"tst_IDMapping","method":"addnew","fieldList":["ID","code"],
  //   "__fieldListExternal":["ID","code"],
  //   "resultData":{"fields":["ID","code"],"rowCount": 1, "data":[[3500000016003,null]]}}]
  var objArray = UB.LocalDataStore.selectResultToArrayOfObjects(result);
  // transform array-of-array result representation to array-of-object
  console.log(objArray);
  // now result in more simple array-of-object format: [{ID: 12312312312, code: null}]
})
 * @module LocalDataStore
 * @memberOf module:@unitybase/cs-shared
 * @author pavel.mash
 */

const _ = require('lodash')
const collationCompare = require('./formatByPattern').collationCompare

/**
 * Format for data, stored in client-side cache
 *
 * @typedef {object} TubCachedData
 * @property {Array<Array>} data
 * @property {Array<string>} fields
 * @property {number} rowCount
 * @property {number} [version] A data version in case `mi_modifyDate` is in fields
 */

/**
 * Perform local filtration and sorting of data array according to ubql whereList & order list.
 *
 * **WARNING** - sub-queries are not supported.
 *
 * @param {TubCachedData} cachedData Data, retrieved from cache
 * @param {UBQL} ubql Initial server request
 * @returns {{resultData: TubCachedData, total: number}} new filtered & sorted array
 */
module.exports.doFilterAndSort = function doFilterAndSort (cachedData, ubql) {
  let rangeStart

  let filteredData = this.doFiltration(cachedData, ubql)
  const totalLength = filteredData.length
  this.doSorting(filteredData, cachedData, ubql)
  // apply options start & limit
  if (ubql.options) {
    rangeStart = ubql.options.start || 0
    if (ubql.options.limit) {
      filteredData = filteredData.slice(rangeStart, rangeStart + ubql.options.limit)
    } else {
      filteredData = filteredData.slice(rangeStart)
    }
  }
  return {
    resultData: {
      data: filteredData,
      fields: cachedData.fields
    },
    total: totalLength
  }
}

/**
 * A helper for search cached data by row ID
 *
 * @param {TubCachedData} cachedData Data, retrieved from cache
 * @param {number} IDValue row ID
 * @returns {{resultData: TubCachedData, total: number}} new filtered & sorted array
 */
module.exports.byID = function byID (cachedData, IDValue) {
  return this.doFilterAndSort(cachedData, { ID: IDValue })
}

/**
 * Apply ubql.whereList to data array and return new array contain filtered data
 *
 * @protected
 * @param {TubCachedData} cachedData Data, retrieved from cache
 * @param {UBQL} ubql
 * @param {boolean} [skipSubQueriesAndCustom=false] Skip `subquery` and `custom` conditions instead of throw. Can be used
 *   to estimate record match some of where conditions
 * @returns {Array.<Array>}
 */
module.exports.doFiltration = function doFiltration (cachedData, ubql, skipSubQueriesAndCustom) {
  if (cachedData.data.length === 0) {
    return []
  }

  let f, isAcceptable
  const rawDataArray = cachedData.data
  const byPrimaryKey = Boolean(ubql.ID)

  const filterFabric = whereListToFunctions(ubql, cachedData.fields, skipSubQueriesAndCustom)
  const filterCount = filterFabric.length

  if (filterCount === 0) {
    return rawDataArray
  }

  const result = []
  const l = rawDataArray.length
  let i = -1
  while (++i < l) { // for each data
    isAcceptable = true; f = -1
    while (++f < filterCount && isAcceptable === true) {
      isAcceptable = filterFabric[f](rawDataArray[i])
    }
    if (isAcceptable) {
      result.push(rawDataArray[i])
      if (byPrimaryKey) {
        return result
      }
    }
  }
  return result
}

/**
 * Apply ubRequest.orderList to inputArray (inputArray is modified)
 *
 * @protected
 * @param {Array.<Array>} filteredArray
 * @param {TubCachedData} cachedData
 * @param {object} ubRequest
 */
module.exports.doSorting = function doSorting (filteredArray, cachedData, ubRequest) {
  const preparedOrder = []
  if (ubRequest.orderList) {
    _.each(ubRequest.orderList, function (orderItem) {
      let oli = orderItem.expression
      if ((oli.charAt(0) === '[') && (oli.charAt(oli.length - 1) === ']')) {
        oli = oli.slice(1, -1)
      }
      const attrIdx = cachedData.fields.indexOf(oli)
      if (attrIdx < 0) {
        throw new Error(`Ordering by "${orderItem.expression}" attribute that not in fieldList is not allowed`)
      }
      preparedOrder.push({
        idx: attrIdx,
        modifier: (orderItem.order === 'desc') ? -1 : 1
      })
    })
    const orderLen = preparedOrder.length
    if (orderLen) {
      const compareFn = function (v1, v2) {
        let res = 0
        let idx = -1
        while (++idx < orderLen && res === 0) {
          const colNum = preparedOrder[idx].idx
          if (v1[colNum] !== v2[colNum]) {
            res = collationCompare(v1[colNum], v2[colNum]) * preparedOrder[idx].modifier
          }
        }
        return res
      }
      filteredArray.sort(compareFn)
    }
  }
}

/**
 * Transform whereList to array of function
 *
 * @private
 * @param {UBQL} ubql
 * @param {Array<string>} fieldList
 * @param {boolean} [skipSubQueriesAndCustom=false] Skip `subquery` and `custom` conditions instead of throw. Can be used
 *   to estimate record match some of where conditions
 * @returns {Array}
 */
function whereListToFunctions (ubql, fieldList, skipSubQueriesAndCustom) {
  Object.keys(ubql) // FIX BUG WITH TubList TODO - rewrite to native
  const whereList = ubql.whereList
  if (!whereList && !ubql.ID) return [] // top level ID adds a primary key filter

  const filters = []
  const escapeForRegexp = function (text) {
    // convert text to string
    return text ? ('' + text).replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') : ''
  }

  const filterFabricFn = function (propertyIdx, condition, value) {
    let regExpFilter
    const valIsStr = typeof value === 'string'
    const valUpperIfStr = valIsStr ? value.toUpperCase() : value
    if (skipSubQueriesAndCustom && ((condition === 'subquery') || (condition === 'custom'))) {
      return null // skip subquery
    }
    switch (condition) {
      case 'like':
        regExpFilter = new RegExp(escapeForRegexp(value), 'i')
        return function (record) {
          const val = record[propertyIdx]
          return val && regExpFilter.test(val)
        }
      case 'equal':
        return function (record) {
          return record[propertyIdx] === value
        }
      case 'notEqual':
        return function (record) {
          return record[propertyIdx] !== value
        }
      case 'more':
        if (valIsStr) {
          return function (record) {
            return collationCompare(record[propertyIdx], value) === 1
          }
        } else {
          return function (record) {
            return record[propertyIdx] > value
          }
        }
      case 'moreEqual':
        if (valIsStr) {
          return function (record) {
            return collationCompare(record[propertyIdx], value) >= 0
          }
        } else {
          return function (record) {
            return record[propertyIdx] >= value
          }
        }
      case 'less':
        if (valIsStr) {
          return function (record) {
            return collationCompare(record[propertyIdx], value) === -1
          }
        } else {
          return function (record) {
            return record[propertyIdx] < value
          }
        }
      case 'lessEqual':
        if (valIsStr) {
          return function (record) {
            return collationCompare(record[propertyIdx], value) <= 0
          }
        } else {
          return function (record) {
            return record[propertyIdx] <= value
          }
        }
      case 'isNull':
        return function (record) {
          return record[propertyIdx] === null
        }
      case 'notIsNull':
        return function (record) {
          return record[propertyIdx] !== null
        }
      case 'notLike':
        regExpFilter = new RegExp(escapeForRegexp(value), 'i')
        return function (record) {
          const val = record[propertyIdx]
          return val && !regExpFilter.test(val)
        }
      case 'startWith':
        return function (record) {
          const str = record[propertyIdx]
          if (!str) return false
          return (str.toUpperCase().indexOf(valUpperIfStr) === 0)
        }
      case 'notStartWith':
        return function (record) {
          const str = record[propertyIdx]
          if (!str) return true
          return str.toUpperCase().indexOf(valUpperIfStr) !== 0
        }
      case 'in':
        return function (record) {
          const str = record[propertyIdx]
          return str && value.indexOf(str) >= 0
        }
      case 'notIn':
        return function (record) {
          const str = record[propertyIdx]
          return str && value.indexOf(str) < 0
        }
      default:
        throw new Error('Unknown whereList condition')
    }
  }

  function transformClause (clause) {
    if (skipSubQueriesAndCustom && ((clause.condition === 'subquery') || (clause.condition === 'custom'))) {
      return // skip subquery and custom
    }

    let property = clause.expression || ''

    if (clause.condition === 'custom') {
      if (property === '0=1') {
        filters.push(() => false)
        return
      }

      if (property === '1=1') {
        filters.push(() => true)
        return
      }

      throw new Error('Condition "custom" is not supported for cached entities')
    }
    property = (property.replace(/(\[)|(])/ig, '') || '').trim()
    const propIdx = fieldList.indexOf(property)
    if (propIdx === -1) {
      throw new Error(`Filtering by attribute "${property}" which is not in fieldList is not allowed for cached entity "${ubql.entity}"`)
    }
    let fValue
    // support for future (UB 5.10) where with "value" instead of "values"
    if (clause.value !== undefined) {
      fValue = clause.value
    } else if (clause.values !== undefined) {
      fValue = clause.values[Object.keys(clause.values)[0]]
    }
    const fn = filterFabricFn(propIdx, clause.condition, fValue)
    if (fn) filters.push(fn)
  }
  // check for top level ID  - in this case add condition for filter by ID
  const reqID = ubql.ID
  if (reqID) {
    transformClause({ expression: '[ID]', condition: 'equal', values: { ID: reqID } })
  }
  const joinAs = new Set(ubql.joinAs || [])
  for (const cName in whereList) {
    if (Object.prototype.hasOwnProperty.call(whereList, cName) && !joinAs.has(cName)) {
      transformClause(whereList[cName])
    }
  }
  return filters
}

module.exports.whereListToFunctions = whereListToFunctions

/**
 * Transform result of {@link class:SyncConnection#select SyncConnection.select} response
 * from Array of Array representation to Array of Object
 *
 * @example
LocalDataStore.selectResultToArrayOfObjects({resultData: {
    data: [['row1_attr1Val', 1], ['row2_attr2Val', 22]],
    fields: ['attrID.name', 'attr2']}
});
// result is:
// [{"attrID.name": "row1_attr1Val", attr2: 1},
//  {"attrID.name": "row2_attr2Val", attr2: 22}
// ]
// object keys simplify by passing fieldAliases
LocalDataStore.selectResultToArrayOfObjects({resultData: {
    data: [['row1_attr1Val', 1], ['row2_attr2Val', 22]],
    fields: ['attrID.name', 'attr2']}
}, {'attrID.name': 'attr1Name'});
// result is:
// [{attr1Name: "row1_attr1Val", attr2: 1},
//  {attr1Name: "row2_attr2Val", attr2: 22}
// ]
 * @param {{resultData: TubCachedData}} selectResult
 * @param {Object<string, string>} [fieldAlias] Optional object to change attribute names during transform array to object. Keys are original names, values - new names
 * @returns {Array<object>}
 */
module.exports.selectResultToArrayOfObjects = function selectResultToArrayOfObjects (selectResult, fieldAlias) {
  const inData = selectResult.resultData.data
  const inAttributes = selectResult.resultData.fields
  const inDataLength = inData.length
  const result = inDataLength ? new Array(inDataLength) : []
  if (fieldAlias) {
    _.forEach(fieldAlias, function (alias, field) {
      const idx = inAttributes.indexOf(field)
      if (idx >= 0) {
        inAttributes[idx] = alias
      }
    })
  }
  for (let i = 0; i < inDataLength; i++) {
    result[i] = _.zipObject(inAttributes, inData[i])
  }
  return result
}

/**
 * Flatten cached data (or result of {@link module:LocalDataStore#doFilterAndSort LocalDataStore.doFilterAndSort}.resultData )
 * to Object expected by TubDataStore.initialize Flatten format (faster than [{}..] format).
 * CachedData may contain more field or field in order not in requestedFieldList - in this case we use expectedFieldList.
 *
 * @example
// consider we have cached data in variable filteredData.resultData
// to initialize dataStore with cached data:
mySelectMethod = function(ctxt){
  const fieldList = ctxt.mParams.fieldList;
  resp = LocalDataStore.flatten(fieldList, filteredData.resultData);
  ctxt.dataStore.initFromJSON(resp);
}
 *
 * @param {Array.<string>} requestedFieldList Array of attributes to transform to. Can be ['*'] - in this case we return all cached attributes
 * @param {TubCachedData} cachedData
 * @returns {{fieldCount: number, rowCount: number, values: Array.<*>}}
 */
module.exports.flatten = function flatten (requestedFieldList, cachedData) {
  const fldIdxArr = []
  const cachedFields = cachedData.fields
  let rowIdx = -1
  let col = -1
  let pos = 0
  const resultData = []
  const rowCount = cachedData.data.length

  if (!requestedFieldList || !requestedFieldList.length) {
    throw new Error('fieldList not exist or empty')
  }

  // client ask for all attributes
  if (requestedFieldList.length === 1 && requestedFieldList[0] === '*') {
    requestedFieldList = cachedData.fields
  }

  requestedFieldList.forEach(function (field) {
    const idx = cachedFields.indexOf(field)
    if (idx !== -1) {
      fldIdxArr.push(idx)
    } else {
      throw new Error('Invalid field list. Attribute ' + field + ' not found in local data store')
    }
  })
  const fieldCount = requestedFieldList.length
  resultData.length = rowCount * (fieldCount + 1) // reserve fieldCount for field names
  while (++col < fieldCount) {
    resultData[pos] = requestedFieldList[pos]; pos++
  }
  while (++rowIdx < rowCount) {
    col = -1
    const row = cachedData.data[rowIdx]
    while (++col < fieldCount) {
      resultData[pos++] = row[fldIdxArr[col]]
    }
  }
  return { fieldCount, rowCount, values: resultData }
}

/**
 * Reverse conversion to {@link module:LocalDataStore#selectResultToArrayOfObjects LocalDataStore.selectResultToArrayOfObjects}.
 *
 *
 * @example
//Transform array of object to array of array using passed attributes array
LocalDataStore.arrayOfObjectsToSelectResult([{a: 1, b: 'as'}, {b: 'other', a: 12}], ['a', 'b']);
// result is: [[1, "as"], [12, "other"]]
 * @param {Array.<object>} arrayOfObject
 * @param {Array.<string>} attributeNames
 * @returns {Array.<Array>}
 */
module.exports.arrayOfObjectsToSelectResult = function arrayOfObjectsToSelectResult (arrayOfObject, attributeNames) {
  const result = []
  arrayOfObject.forEach(function (obj) {
    const row = []
    attributeNames.forEach(function (attribute) {
      row.push(obj[attribute])
    })
    result.push(row)
  })
  return result
}

/**
 * Convert a local DateTime to Date with zero time in UTC0 timezone as expected by UB server for Date attributes
 *
 * @param {Date} v
 * @returns {Date}
 */
module.exports.truncTimeToUtcNull = function truncTimeToUtcNull (v) {
  if (!v) return v
  let m = v.getMonth() + 1
  m = m < 10 ? '0' + m : '' + m
  let d = v.getDate()
  d = d < 10 ? '0' + d : '' + d
  return new Date(`${v.getFullYear()}-${m}-${d}T00:00:00Z`)
  // code below fails for 1988-03-27
  // var result = new Date(v.getFullYear(), v.getMonth(), v.getDate())
  // result.setMinutes(-v.getTimezoneOffset())
  // return result
}

/**
 * Convert UnityBase server date response to Date object.
 * Date response is a day with 00 time (2015-07-17T00:00Z), to get a real date we must add current timezone shift
 *
 * @param {string|Date|*} val
 * @returns {Date}
 */
module.exports.iso8601ParseAsDate = function iso8601ParseAsDate (val) {
  if (!val) return null
  let res = null
  if (typeof val === 'string') {
    const year = parseInt(val.substring(0, 4), 10)
    const month = parseInt(val.substring(5, 7), 10)
    const day = parseInt(val.substring(8, 10), 10)
    if (Number.isInteger(year) && Number.isInteger(month) && Number.isInteger(day)) {
      res = new Date(year, month - 1, day)
    }
  } else {
    res = new Date(val)
    if (res) {
      res = new Date(res.getFullYear(), res.getMonth(), res.getDate())
      // code below fails for 1988-03-27T00:00Z
      // res.setTime(res.getTime() + res.getTimezoneOffset() * 60 * 1000)
    }
  }
  return res
}

/**
 * Convert raw server response data to javaScript data according to attribute types
 *
 * @param {UBDomain} domain
 * @param serverResponse
 * @returns {*}
 */
module.exports.convertResponseDataToJsTypes = function convertResponseDataToJsTypes (domain, serverResponse) {
  if (serverResponse.entity && // fieldList &&  serverResponse.fieldList
    serverResponse.resultData &&
    !serverResponse.resultData.notModified &&
    serverResponse.resultData.fields &&
    serverResponse.resultData.data && serverResponse.resultData.data.length
  ) {
    const convertRules = domain.get(serverResponse.entity).getConvertRules(serverResponse.resultData.fields)
    const rulesLen = convertRules.length
    const data = serverResponse.resultData.data
    if (rulesLen) {
      for (let d = 0, dataLen = data.length; d < dataLen; d++) {
        for (let r = 0; r < rulesLen; r++) {
          const column = convertRules[r].index
          data[d][column] = convertRules[r].convertFn(data[d][column])
        }
      }
    }
  }
  if (serverResponse.resultLock && serverResponse.resultLock.lockTime) {
    serverResponse.resultLock.lockTime = serverResponse.resultLock.lockTime ? new Date(serverResponse.resultLock.lockTime) : null
  }
  return serverResponse
}