const linkRows = require("../../db/linkedRows")
const { cloneDeep } = require("lodash/fp")
const { FieldTypes, AutoFieldSubTypes } = require("../../constants")
const { attachmentsRelativeURL } = require("../index")
const { processFormulas } = require("./utils")
const { deleteFiles } = require("../../utilities/fileSystem/utilities")
const { ObjectStoreBuckets } = require("../../constants")
const {
  isProdAppID,
  getProdAppID,
  dbExists,
} = require("@budibase/backend-core/db")
const { getAppId } = require("@budibase/backend-core/context")

const BASE_AUTO_ID = 1

/**
 * A map of how we convert various properties in rows to each other based on the row type.
 */
const TYPE_TRANSFORM_MAP = {
  [FieldTypes.LINK]: {
    "": [],
    [null]: [],
    [undefined]: undefined,
    parse: link => {
      if (Array.isArray(link) && typeof link[0] === "object") {
        return link.map(el => (el && el._id ? el._id : el))
      }
      if (typeof link === "string") {
        return [link]
      }
      return link
    },
  },
  [FieldTypes.OPTIONS]: {
    "": null,
    [null]: null,
    [undefined]: undefined,
  },
  [FieldTypes.ARRAY]: {
    "": [],
    [null]: [],
    [undefined]: undefined,
  },
  [FieldTypes.STRING]: {
    "": "",
    [null]: "",
    [undefined]: undefined,
  },
  [FieldTypes.FORMULA]: {
    "": "",
    [null]: "",
    [undefined]: undefined,
  },
  [FieldTypes.LONGFORM]: {
    "": "",
    [null]: "",
    [undefined]: undefined,
  },
  [FieldTypes.NUMBER]: {
    "": null,
    [null]: null,
    [undefined]: undefined,
    parse: n => parseFloat(n),
  },
  [FieldTypes.DATETIME]: {
    "": null,
    [undefined]: undefined,
    [null]: null,
    parse: date => {
      if (date instanceof Date) {
        return date.toISOString()
      }
      return date
    },
  },
  [FieldTypes.ATTACHMENT]: {
    "": [],
    [null]: [],
    [undefined]: undefined,
  },
  [FieldTypes.BOOLEAN]: {
    "": null,
    [null]: null,
    [undefined]: undefined,
    true: true,
    false: false,
  },
  [FieldTypes.AUTO]: {
    parse: () => undefined,
  },
  [FieldTypes.JSON]: {
    parse: input => {
      try {
        if (input === "") {
          return undefined
        }
        return JSON.parse(input)
      } catch (err) {
        return input
      }
    },
  },
}

/**
 * Given the old state of the row and the new one after an update, this will
 * find the keys that have been removed in the updated row.
 */
function getRemovedAttachmentKeys(oldRow, row, attachmentKey) {
  if (!oldRow[attachmentKey]) {
    return []
  }
  const oldKeys = oldRow[attachmentKey].map(attachment => attachment.key)
  // no attachments in new row, all removed
  if (!row[attachmentKey]) {
    return oldKeys
  }
  const newKeys = row[attachmentKey].map(attachment => attachment.key)
  return oldKeys.filter(key => newKeys.indexOf(key) === -1)
}

/**
 * This will update any auto columns that are found on the row/table with the correct information based on
 * time now and the current logged in user making the request.
 * @param {Object} user The user to be used for an appId as well as the createdBy and createdAt fields.
 * @param {Object} table The table which is to be used for the schema, as well as handling auto IDs incrementing.
 * @param {Object} row The row which is to be updated with information for the auto columns.
 * @param {Object} opts specific options for function to carry out optional features.
 * @returns {{row: Object, table: Object}} The updated row and table, the table may need to be updated
 * for automatic ID purposes.
 */
function processAutoColumn(
  user,
  table,
  row,
  opts = { reprocessing: false, noAutoRelationships: false }
) {
  let noUser = !user || !user.userId
  let now = new Date().toISOString()
  // if a row doesn't have a revision then it doesn't exist yet
  const creating = !row._rev
  for (let [key, schema] of Object.entries(table.schema)) {
    if (!schema.autocolumn) {
      continue
    }
    switch (schema.subtype) {
      case AutoFieldSubTypes.CREATED_BY:
        if (
          creating &&
          !opts.reprocessing &&
          !opts.noAutoRelationships &&
          !noUser
        ) {
          row[key] = [user.userId]
        }
        break
      case AutoFieldSubTypes.CREATED_AT:
        if (creating) {
          row[key] = now
        }
        break
      case AutoFieldSubTypes.UPDATED_BY:
        if (!opts.reprocessing && !opts.noAutoRelationships && !noUser) {
          row[key] = [user.userId]
        }
        break
      case AutoFieldSubTypes.UPDATED_AT:
        row[key] = now
        break
      case AutoFieldSubTypes.AUTO_ID:
        if (creating) {
          schema.lastID = !schema.lastID ? BASE_AUTO_ID : schema.lastID + 1
          row[key] = schema.lastID
        }
        break
    }
  }
  return { table, row }
}
exports.processAutoColumn = processAutoColumn

exports.processFormulas = processFormulas

/**
 * This will coerce a value to the correct types based on the type transform map
 * @param {object} row The value to coerce
 * @param {object} type The type fo coerce to
 * @returns {object} The coerced value
 */
exports.coerce = (row, type) => {
  // no coercion specified for type, skip it
  if (!TYPE_TRANSFORM_MAP[type]) {
    return row
  }
  // eslint-disable-next-line no-prototype-builtins
  if (TYPE_TRANSFORM_MAP[type].hasOwnProperty(row)) {
    return TYPE_TRANSFORM_MAP[type][row]
  } else if (TYPE_TRANSFORM_MAP[type].parse) {
    return TYPE_TRANSFORM_MAP[type].parse(row)
  }

  return row
}

/**
 * Given an input route this function will apply all the necessary pre-processing to it, such as coercion
 * of column values or adding auto-column values.
 * @param {object} user the user which is performing the input.
 * @param {object} row the row which is being created/updated.
 * @param {object} table the table which the row is being saved to.
 * @param {object} opts some input processing options (like disabling auto-column relationships).
 * @returns {object} the row which has been prepared to be written to the DB.
 */
exports.inputProcessing = (
  user = {},
  table,
  row,
  opts = { noAutoRelationships: false }
) => {
  let clonedRow = cloneDeep(row)
  // need to copy the table so it can be differenced on way out
  const copiedTable = cloneDeep(table)
  const dontCleanseKeys = ["type", "_id", "_rev", "tableId"]
  for (let [key, value] of Object.entries(clonedRow)) {
    const field = table.schema[key]
    // cleanse fields that aren't in the schema
    if (!field) {
      if (dontCleanseKeys.indexOf(key) === -1) {
        delete clonedRow[key]
      }
      continue
    }
    // remove any formula values, they are to be generated
    if (field.type === FieldTypes.FORMULA) {
      delete clonedRow[key]
    }
    // otherwise coerce what is there to correct types
    else {
      clonedRow[key] = exports.coerce(value, field.type)
    }
  }

  if (!clonedRow._id || !clonedRow._rev) {
    clonedRow._id = row._id
    clonedRow._rev = row._rev
  }

  // handle auto columns - this returns an object like {table, row}
  return processAutoColumn(user, copiedTable, clonedRow, opts)
}

/**
 * This function enriches the input rows with anything they are supposed to contain, for example
 * link records or attachment links.
 * @param {object} table the table from which these rows came from originally, this is used to determine
 * the schema of the rows and then enrich.
 * @param {object[]|object} rows the rows which are to be enriched.
 * @param {object} opts used to set some options for the output, such as disabling relationship squashing.
 * @returns {object[]|object} the enriched rows will be returned.
 */
exports.outputProcessing = async (table, rows, opts = { squash: true }) => {
  let wasArray = true
  if (!(rows instanceof Array)) {
    rows = [rows]
    wasArray = false
  }
  // attach any linked row information
  let enriched = await linkRows.attachFullLinkedDocs(table, rows)

  // process formulas
  enriched = processFormulas(table, enriched, { dynamic: true })

  // update the attachments URL depending on hosting
  for (let [property, column] of Object.entries(table.schema)) {
    if (column.type === FieldTypes.ATTACHMENT) {
      for (let row of enriched) {
        if (row[property] == null || row[property].length === 0) {
          continue
        }
        row[property].forEach(attachment => {
          attachment.url = attachmentsRelativeURL(attachment.key)
        })
      }
    }
  }
  if (opts.squash) {
    enriched = await linkRows.squashLinksToPrimaryDisplay(table, enriched)
  }
  return wasArray ? enriched : enriched[0]
}

/**
 * Clean up any attachments that were attached to a row.
 * @param {object} table The table from which a row is being removed.
 * @param {any} row optional - the row being removed.
 * @param {any} rows optional - if multiple rows being deleted can do this in bulk.
 * @param {any} oldRow optional - if updating a row this will determine the difference.
 * @param {any} oldTable optional - if updating a table, can supply the old table to look for
 * deleted attachment columns.
 * @return {Promise<void>} When all attachments have been removed this will return.
 */
exports.cleanupAttachments = async (table, { row, rows, oldRow, oldTable }) => {
  const appId = getAppId()
  if (!isProdAppID(appId)) {
    const prodAppId = getProdAppID(appId)
    // if prod exists, then don't allow deleting
    const exists = await dbExists(prodAppId)
    if (exists) {
      return
    }
  }
  let files = []
  function addFiles(row, key) {
    if (row[key]) {
      files = files.concat(row[key].map(attachment => attachment.key))
    }
  }
  const schemaToUse = oldTable ? oldTable.schema : table.schema
  for (let [key, schema] of Object.entries(schemaToUse)) {
    if (schema.type !== FieldTypes.ATTACHMENT) {
      continue
    }
    // old table had this column, new table doesn't - delete it
    if (oldTable && !table.schema[key]) {
      rows.forEach(row => addFiles(row, key))
    } else if (oldRow && row) {
      // if updating, need to manage the differences
      files = files.concat(getRemovedAttachmentKeys(oldRow, row, key))
    } else if (row) {
      addFiles(row, key)
    } else if (rows) {
      rows.forEach(row => addFiles(row, key))
    }
  }
  if (files.length > 0) {
    return deleteFiles(ObjectStoreBuckets.APPS, files)
  }
}
