import { cloneDeep } from "lodash/fp"
import { get } from "svelte/store"
import {
  findAllMatchingComponents,
  findComponent,
  findComponentPath,
  getComponentSettings,
} from "./componentUtils"
import { store } from "builderStore"
import {
  queries as queriesStores,
  tables as tablesStore,
  roles as rolesStore,
} from "stores/backend"
import {
  makePropSafe,
  isJSBinding,
  decodeJSBinding,
  encodeJSBinding,
} from "@budibase/string-templates"
import { TableNames } from "../constants"
import { JSONUtils } from "@budibase/frontend-core"
import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json"

// Regex to match all instances of template strings
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
const CAPTURE_VAR_INSIDE_JS = /\$\("([^")]+)"\)/g
const CAPTURE_HBS_TEMPLATE = /{{[\S\s]*?}}/g

/**
 * Gets all bindable data context fields and instance fields.
 */
export const getBindableProperties = (asset, componentId) => {
  const contextBindings = getContextBindings(asset, componentId)
  const userBindings = getUserBindings()
  const urlBindings = getUrlBindings(asset)
  const deviceBindings = getDeviceBindings()
  const stateBindings = getStateBindings()
  const selectedRowsBindings = getSelectedRowsBindings(asset)
  const roleBindings = getRoleBindings()
  return [
    ...contextBindings,
    ...urlBindings,
    ...stateBindings,
    ...userBindings,
    ...deviceBindings,
    ...selectedRowsBindings,
    ...roleBindings,
  ]
}

/**
 * Gets all rest bindable data fields
 */
export const getRestBindings = () => {
  const userBindings = getUserBindings()
  return [...userBindings, ...getAuthBindings()]
}

/**
 * Gets all rest bindable auth fields
 */
export const getAuthBindings = () => {
  let bindings = []
  const safeUser = makePropSafe("user")
  const safeOAuth2 = makePropSafe("oauth2")
  const safeAccessToken = makePropSafe("accessToken")

  const authBindings = [
    {
      runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`,
      readable: `Current User.OAuthToken`,
      key: "accessToken",
    },
  ]

  bindings = Object.keys(authBindings).map(key => {
    const fieldBinding = authBindings[key]
    return {
      type: "context",
      runtimeBinding: fieldBinding.runtime,
      readableBinding: fieldBinding.readable,
      fieldSchema: { type: "string", name: fieldBinding.key },
      providerId: "user",
    }
  })
  return bindings
}

/**
 * Utility - convert a key/value map to an array of custom 'context' bindings
 * @param {object} valueMap Key/value pairings
 * @param {string} prefix A contextual string prefix/path for a user readable binding
 * @return {object[]} An array containing readable/runtime binding objects
 */
export const toBindingsArray = (valueMap, prefix) => {
  if (!valueMap) {
    return []
  }
  return Object.keys(valueMap).reduce((acc, binding) => {
    if (!binding || !valueMap[binding]) {
      return acc
    }
    acc.push({
      type: "context",
      runtimeBinding: binding,
      readableBinding: `${prefix}.${binding}`,
    })
    return acc
  }, [])
}

/**
 * Utility - coverting a map of readable bindings to runtime
 */
export const readableToRuntimeMap = (bindings, ctx) => {
  if (!bindings || !ctx) {
    return {}
  }
  return Object.keys(ctx).reduce((acc, key) => {
    let parsedQuery = readableToRuntimeBinding(bindings, ctx[key])
    acc[key] = parsedQuery
    return acc
  }, {})
}

/**
 * Utility - coverting a map of runtime bindings to readable
 */
export const runtimeToReadableMap = (bindings, ctx) => {
  if (!bindings || !ctx) {
    return {}
  }
  return Object.keys(ctx).reduce((acc, key) => {
    let parsedQuery = runtimeToReadableBinding(bindings, ctx[key])
    acc[key] = parsedQuery
    return acc
  }, {})
}

/**
 * Gets the bindable properties exposed by a certain component.
 */
export const getComponentBindableProperties = (asset, componentId) => {
  if (!asset || !componentId) {
    return []
  }

  // Ensure that the component exists and exposes context
  const component = findComponent(asset.props, componentId)
  const def = store.actions.components.getDefinition(component?._component)
  if (!def?.context) {
    return []
  }

  // Get the bindings for the component
  return getProviderContextBindings(asset, component)
}

/**
 * Gets all data provider components above a component.
 */
export const getContextProviderComponents = (asset, componentId, type) => {
  if (!asset || !componentId) {
    return []
  }

  // Get the component tree leading up to this component, ignoring the component
  // itself
  const path = findComponentPath(asset.props, componentId)
  path.pop()

  // Filter by only data provider components
  return path.filter(component => {
    const def = store.actions.components.getDefinition(component._component)
    if (!def?.context) {
      return false
    }

    // If no type specified, return anything that exposes context
    if (!type) {
      return true
    }

    // Otherwise only match components with the specific context type
    const contexts = Array.isArray(def.context) ? def.context : [def.context]
    return contexts.find(context => context.type === type) != null
  })
}

/**
 * Gets all data provider components above a component.
 */
export const getActionProviderComponents = (asset, componentId, actionType) => {
  if (!asset || !componentId) {
    return []
  }

  // Get the component tree leading up to this component, ignoring the component
  // itself
  const path = findComponentPath(asset.props, componentId)
  path.pop()

  // Filter by only data provider components
  return path.filter(component => {
    const def = store.actions.components.getDefinition(component._component)
    return def?.actions?.includes(actionType)
  })
}

/**
 * Gets a datasource object for a certain data provider component
 */
export const getDatasourceForProvider = (asset, component) => {
  const settings = getComponentSettings(component?._component)

  // If this component has a dataProvider setting, go up the stack and use it
  const dataProviderSetting = settings.find(setting => {
    return setting.type === "dataProvider"
  })
  if (dataProviderSetting) {
    const settingValue = component[dataProviderSetting.key]
    const providerId = extractLiteralHandlebarsID(settingValue)
    const provider = findComponent(asset?.props, providerId)
    return getDatasourceForProvider(asset, provider)
  }

  // Extract datasource from component instance
  const validSettingTypes = ["dataSource", "table", "schema"]
  const datasourceSetting = settings.find(setting => {
    return validSettingTypes.includes(setting.type)
  })
  if (!datasourceSetting) {
    return null
  }

  // There are different types of setting which can be a datasource, for
  // example an actual datasource object, or a table ID string.
  // Convert the datasource setting into a proper datasource object so that
  // we can use it properly
  if (datasourceSetting.type === "table") {
    return {
      tableId: component[datasourceSetting?.key],
      type: "table",
    }
  } else {
    return component[datasourceSetting?.key]
  }
}

/**
 * Gets all bindable data properties from component data contexts.
 */
const getContextBindings = (asset, componentId) => {
  // Extract any components which provide data contexts
  const dataProviders = getContextProviderComponents(asset, componentId)

  // Generate bindings for all matching components
  return getProviderContextBindings(asset, dataProviders)
}

/**
 * Gets the context bindings exposed by a set of data provider components.
 */
const getProviderContextBindings = (asset, dataProviders) => {
  if (!asset || !dataProviders) {
    return []
  }

  // Ensure providers is an array
  if (!Array.isArray(dataProviders)) {
    dataProviders = [dataProviders]
  }

  // Create bindings for each data provider
  let bindings = []
  dataProviders.forEach(component => {
    const def = store.actions.components.getDefinition(component._component)
    const contexts = Array.isArray(def.context) ? def.context : [def.context]

    // Create bindings for each context block provided by this data provider
    contexts.forEach(context => {
      if (!context?.type) {
        return
      }

      let schema
      let table
      let readablePrefix
      let runtimeSuffix = context.suffix

      if (context.type === "form") {
        // Forms do not need table schemas
        // Their schemas are built from their component field names
        schema = buildFormSchema(component)
        readablePrefix = "Fields"
      } else if (context.type === "static") {
        // Static contexts are fully defined by the components
        schema = {}
        const values = context.values || []
        values.forEach(value => {
          schema[value.key] = {
            name: value.label,
            type: value.type || "string",
          }
        })
      } else if (context.type === "schema") {
        // Schema contexts are generated dynamically depending on their data
        const datasource = getDatasourceForProvider(asset, component)
        if (!datasource) {
          return
        }
        const info = getSchemaForDatasource(asset, datasource)
        schema = info.schema
        table = info.table

        // For JSON arrays, use the array name as the readable prefix.
        // Otherwise use the table name
        if (datasource.type === "jsonarray") {
          const split = datasource.label.split(".")
          readablePrefix = split[split.length - 1]
        } else {
          readablePrefix = info.table?.name
        }
      }
      if (!schema) {
        return
      }

      const keys = Object.keys(schema).sort()

      // Generate safe unique runtime prefix
      let providerId = component._id
      if (runtimeSuffix) {
        providerId += `-${runtimeSuffix}`
      }
      const safeComponentId = makePropSafe(providerId)

      // Create bindable properties for each schema field
      keys.forEach(key => {
        const fieldSchema = schema[key]

        // Make safe runtime binding
        const safeKey = key.split(".").map(makePropSafe).join(".")
        const runtimeBinding = `${safeComponentId}.${safeKey}`

        // Optionally use a prefix with readable bindings
        let readableBinding = component._instanceName
        if (readablePrefix) {
          readableBinding += `.${readablePrefix}`
        }
        readableBinding += `.${fieldSchema.name || key}`

        // Create the binding object
        bindings.push({
          type: "context",
          runtimeBinding,
          readableBinding,
          // Field schema and provider are required to construct relationship
          // datasource options, based on bindable properties
          fieldSchema,
          providerId,
          // Table ID is used by JSON fields to know what table the field is in
          tableId: table?._id,
          category: component._instanceName,
          icon: def.icon,
          display: {
            name: fieldSchema.name || key,
            type: fieldSchema.type,
          },
        })
      })
    })
  })

  return bindings
}

/**
 * Gets all bindable properties from the logged in user.
 */
const getUserBindings = () => {
  let bindings = []
  const { schema } = getSchemaForTable(TableNames.USERS)
  const keys = Object.keys(schema).sort()
  const safeUser = makePropSafe("user")
  keys.forEach(key => {
    const fieldSchema = schema[key]
    bindings.push({
      type: "context",
      runtimeBinding: `${safeUser}.${makePropSafe(key)}`,
      readableBinding: `Current User.${key}`,
      // Field schema and provider are required to construct relationship
      // datasource options, based on bindable properties
      fieldSchema,
      providerId: "user",
      category: "Current User",
      icon: "User",
      display: fieldSchema,
    })
  })
  return bindings
}

/**
 * Gets all device bindings that are globally available.
 */
const getDeviceBindings = () => {
  let bindings = []
  if (get(store).clientFeatures?.deviceAwareness) {
    const safeDevice = makePropSafe("device")
    bindings.push({
      type: "context",
      runtimeBinding: `${safeDevice}.${makePropSafe("mobile")}`,
      readableBinding: `Device.Mobile`,
      category: "Device",
      icon: "DevicePhone",
      display: { type: "boolean", name: "mobile" },
    })
    bindings.push({
      type: "context",
      runtimeBinding: `${safeDevice}.${makePropSafe("tablet")}`,
      readableBinding: `Device.Tablet`,
      category: "Device",
      icon: "DevicePhone",
      display: { type: "boolean", name: "tablet" },
    })
  }
  return bindings
}

/**
 * Gets all selected rows bindings for tables in the current asset.
 */
const getSelectedRowsBindings = asset => {
  let bindings = []
  if (get(store).clientFeatures?.rowSelection) {
    // Add bindings for table components
    let tables = findAllMatchingComponents(asset?.props, component =>
      component._component.endsWith("table")
    )
    const safeState = makePropSafe("rowSelection")
    bindings = bindings.concat(
      tables.map(table => ({
        type: "context",
        runtimeBinding: `${safeState}.${makePropSafe(table._id)}.${makePropSafe(
          "selectedRows"
        )}`,
        readableBinding: `${table._instanceName}.Selected rows`,
        category: "Selected rows",
        icon: "ViewRow",
      }))
    )

    // Add bindings for table blocks
    let tableBlocks = findAllMatchingComponents(asset?.props, component =>
      component._component.endsWith("tableblock")
    )
    bindings = bindings.concat(
      tableBlocks.map(block => ({
        type: "context",
        runtimeBinding: `${safeState}.${makePropSafe(
          block._id + "-table"
        )}.${makePropSafe("selectedRows")}`,
        readableBinding: `${block._instanceName}.Selected rows`,
      }))
    )
  }
  return bindings
}

/**
 * Gets all state bindings that are globally available.
 */
const getStateBindings = () => {
  let bindings = []
  if (get(store).clientFeatures?.state) {
    const safeState = makePropSafe("state")
    bindings = getAllStateVariables().map(key => ({
      type: "context",
      runtimeBinding: `${safeState}.${makePropSafe(key)}`,
      readableBinding: `State.${key}`,
      category: "State",
      icon: "AutomatedSegment",
      display: { name: key },
    }))
  }
  return bindings
}

/**
 * Gets all bindable properties from URL parameters.
 */
const getUrlBindings = asset => {
  const url = asset?.routing?.route ?? ""
  const split = url.split("/")
  let params = []
  split.forEach(part => {
    if (part.startsWith(":") && part.length > 1) {
      params.push(part.replace(/:/g, "").replace(/\?/g, ""))
    }
  })
  const safeURL = makePropSafe("url")
  const urlParamBindings = params.map(param => ({
    type: "context",
    runtimeBinding: `${safeURL}.${makePropSafe(param)}`,
    readableBinding: `URL.${param}`,
    category: "URL",
    icon: "RailTop",
    display: { type: "string" },
  }))
  const queryParamsBinding = {
    type: "context",
    runtimeBinding: makePropSafe("query"),
    readableBinding: "Query params",
    category: "URL",
    icon: "RailTop",
    display: { type: "object" },
  }
  return urlParamBindings.concat([queryParamsBinding])
}

const getRoleBindings = () => {
  return (get(rolesStore) || []).map(role => {
    return {
      type: "context",
      runtimeBinding: `trim "${role._id}"`,
      readableBinding: `Role.${role.name}`,
      category: "Role",
      icon: "UserGroup",
      display: { type: "string", name: role.name },
    }
  })
}

/**
 * Gets all bindable properties exposed in an event action flow up until
 * the specified action ID, as well as context provided for the action
 * setting as a whole by the component.
 */
export const getEventContextBindings = (
  asset,
  componentId,
  settingKey,
  actions,
  actionId
) => {
  let bindings = []

  // Check if any context bindings are provided by the component for this
  // setting
  const component = findComponent(asset.props, componentId)
  const def = store.actions.components.getDefinition(component?._component)
  const settings = getComponentSettings(component?._component)
  const eventSetting = settings.find(setting => setting.key === settingKey)
  if (eventSetting?.context?.length) {
    eventSetting.context.forEach(contextEntry => {
      bindings.push({
        readableBinding: contextEntry.label,
        runtimeBinding: `${makePropSafe("eventContext")}.${makePropSafe(
          contextEntry.key
        )}`,
        category: component._instanceName,
        icon: def.icon,
      })
    })
  }

  // Get the steps leading up to this value
  const index = actions?.findIndex(action => action.id === actionId)
  if (index == null || index === -1) {
    return bindings
  }
  const prevActions = actions.slice(0, index)

  // Generate bindings for any steps which provide context
  prevActions.forEach((action, idx) => {
    const def = ActionDefinitions.actions.find(
      x => x.name === action["##eventHandlerType"]
    )
    if (def.context) {
      def.context.forEach(contextValue => {
        bindings.push({
          readableBinding: `Action ${idx + 1}.${contextValue.label}`,
          runtimeBinding: `actions.${idx}.${contextValue.value}`,
          category: "Actions",
          icon: "JourneyAction",
        })
      })
    }
  })

  return bindings
}

/**
 * Gets the schema for a certain table ID.
 * The options which can be passed in are:
 *   formSchema: whether the schema is for a form
 *   searchableSchema: whether to generate a searchable schema, which may have
 *     fewer fields than a readable schema
 * @param tableId the table ID to get the schema for
 * @param options options for generating the schema
 * @return {{schema: Object, table: Object}}
 */
export const getSchemaForTable = (tableId, options) => {
  return getSchemaForDatasource(null, { type: "table", tableId }, options)
}

/**
 * Gets a schema for a datasource object.
 * The options which can be passed in are:
 *   formSchema: whether the schema is for a form
 *   searchableSchema: whether to generate a searchable schema, which may have
 *     fewer fields than a readable schema
 * @param asset the current root client app asset (layout or screen). This is
 *   optional and only needed for "provider" datasource types.
 * @param datasource the datasource definition
 * @param options options for generating the schema
 * @return {{schema: Object, table: Object}}
 */
export const getSchemaForDatasource = (asset, datasource, options) => {
  options = options || {}
  let schema, table

  if (datasource) {
    const { type } = datasource
    const tables = get(tablesStore).list

    // Determine the entity which backs this datasource.
    // "provider" datasources are those targeting another data provider
    if (type === "provider") {
      const component = findComponent(asset?.props, datasource.providerId)
      const source = getDatasourceForProvider(asset, component)
      return getSchemaForDatasource(asset, source, options)
    }

    // "query" datasources are those targeting non-plus datasources or
    // custom queries
    else if (type === "query") {
      const queries = get(queriesStores).list
      table = queries.find(query => query._id === datasource._id)
    }

    // "field" datasources are array-like fields of rows, such as attachments
    // or multi-select fields
    else if (type === "field") {
      table = { name: datasource.fieldName }
      const { fieldType } = datasource
      if (fieldType === "attachment") {
        schema = {
          url: {
            type: "string",
          },
          name: {
            type: "string",
          },
        }
      } else if (fieldType === "array") {
        schema = {
          value: {
            type: "string",
          },
        }
      }
    }

    // "jsonarray" datasources are arrays inside JSON fields
    else if (type === "jsonarray") {
      table = tables.find(table => table._id === datasource.tableId)
      let tableSchema = table?.schema
      schema = JSONUtils.getJSONArrayDatasourceSchema(tableSchema, datasource)
    }

    // Otherwise we assume we're targeting an internal table or a plus
    // datasource, and we can treat it as a table with a schema
    else {
      table = tables.find(table => table._id === datasource.tableId)
    }

    // Determine the schema from the backing entity if not already determined
    if (table && !schema) {
      if (type === "view") {
        // For views, the schema is pulled from the `views` property of the
        // table
        schema = cloneDeep(table.views?.[datasource.name]?.schema)
      } else if (
        type === "query" &&
        (options.formSchema || options.searchableSchema)
      ) {
        // For queries, if we are generating a schema for a form or a searchable
        // schema then we want to use the query parameters rather than the
        // query schema
        schema = {}
        const params = table.parameters || []
        params.forEach(param => {
          if (param?.name) {
            schema[param.name] = { ...param, type: "string" }
          }
        })
      } else {
        // Otherwise we just want the schema of the table
        schema = cloneDeep(table.schema)
      }
    }

    // Check for any JSON fields so we can add any top level properties
    if (schema) {
      let jsonAdditions = {}
      Object.keys(schema).forEach(fieldKey => {
        const fieldSchema = schema[fieldKey]
        if (fieldSchema?.type === "json") {
          const jsonSchema = JSONUtils.convertJSONSchemaToTableSchema(
            fieldSchema,
            {
              squashObjects: true,
            }
          )
          Object.keys(jsonSchema).forEach(jsonKey => {
            jsonAdditions[`${fieldKey}.${jsonKey}`] = {
              type: jsonSchema[jsonKey].type,
              nestedJSON: true,
            }
          })
        }
      })
      schema = { ...schema, ...jsonAdditions }
    }

    // Determine if we should add ID and rev to the schema
    const isInternal = table && !table.sql
    const isTable = ["table", "link"].includes(datasource.type)

    // ID is part of the readable schema for all tables
    // Rev is part of the readable schema for internal tables only
    let addId = isTable
    let addRev = isTable && isInternal

    // Don't add ID or rev for form schemas
    if (options.formSchema) {
      addId = false
      addRev = false
    }

    // ID is only searchable for internal tables
    else if (options.searchableSchema) {
      addId = isTable && isInternal
    }

    // Add schema properties if required
    if (schema) {
      if (addId) {
        schema["_id"] = { type: "string" }
      }
      if (addRev) {
        schema["_rev"] = { type: "string" }
      }
    }

    // Ensure there are "name" properties for all fields and that field schema
    // are objects
    let fixedSchema = {}
    Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => {
      if (typeof fieldSchema === "string") {
        fixedSchema[fieldName] = {
          type: fieldSchema,
          name: fieldName,
        }
      } else {
        fixedSchema[fieldName] = {
          ...fieldSchema,
          name: fieldName,
        }
      }
    })
    schema = fixedSchema
  }
  return { schema, table }
}

/**
 * Builds a form schema given a form component.
 * A form schema is a schema of all the fields nested anywhere within a form.
 */
export const buildFormSchema = component => {
  let schema = {}
  if (!component) {
    return schema
  }
  const settings = getComponentSettings(component._component)
  const fieldSetting = settings.find(
    setting => setting.key === "field" && setting.type.startsWith("field/")
  )
  if (fieldSetting && component.field) {
    const type = fieldSetting.type.split("field/")[1]
    if (type) {
      schema[component.field] = { type }
    }
  }
  component._children?.forEach(child => {
    const childSchema = buildFormSchema(child)
    schema = { ...schema, ...childSchema }
  })
  return schema
}

/**
 * Returns an array of the keys of any state variables which are set anywhere
 * in the app.
 */
export const getAllStateVariables = () => {
  // Find all button action settings in all components
  let eventSettings = []
  getAllAssets().forEach(asset => {
    findAllMatchingComponents(asset.props, component => {
      const settings = getComponentSettings(component._component)
      settings
        .filter(setting => setting.type === "event")
        .forEach(setting => {
          eventSettings.push(component[setting.key])
        })
    })
  })

  // Add on load settings from screens
  get(store).screens.forEach(screen => {
    if (screen.onLoad) {
      eventSettings.push(screen.onLoad)
    }
  })

  // Extract all state keys from any "update state" actions in each setting
  let bindingSet = new Set()
  eventSettings.forEach(setting => {
    if (!Array.isArray(setting)) {
      return
    }
    setting.forEach(action => {
      if (
        action["##eventHandlerType"] === "Update State" &&
        action.parameters?.type === "set" &&
        action.parameters?.key &&
        action.parameters?.value
      ) {
        bindingSet.add(action.parameters.key)
      }
    })
  })
  return Array.from(bindingSet)
}

export const getAllAssets = () => {
  // Get all component containing assets
  let allAssets = []
  allAssets = allAssets.concat(get(store).layouts || [])
  allAssets = allAssets.concat(get(store).screens || [])

  return allAssets
}

/**
 * Recurses the input object to remove any instances of bindings.
 */
export const removeBindings = (obj, replacement = "Invalid binding") => {
  for (let [key, value] of Object.entries(obj)) {
    if (value && typeof value === "object") {
      obj[key] = removeBindings(value, replacement)
    } else if (typeof value === "string") {
      obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, replacement)
    }
  }
  return obj
}

/**
 * When converting from readable to runtime it can sometimes add too many square brackets,
 * this makes sure that doesn't happen.
 */
const shouldReplaceBinding = (currentValue, convertFrom, convertTo) => {
  if (!currentValue?.includes(convertFrom)) {
    return false
  }
  if (convertTo === "readableBinding") {
    return true
  }
  // remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then
  // this makes sure it is detected
  const noSpaces = currentValue.replace(/\s+/g, "")
  const fromNoSpaces = convertFrom.replace(/\s+/g, "")
  const invalids = [
    `[${fromNoSpaces}]`,
    `"${fromNoSpaces}"`,
    `'${fromNoSpaces}'`,
  ]
  return !invalids.find(invalid => noSpaces?.includes(invalid))
}

/**
 * Utility function which replaces a string between given indices.
 */
const replaceBetween = (string, start, end, replacement) => {
  return string.substring(0, start) + replacement + string.substring(end)
}

/**
 * Utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
 */
const bindingReplacement = (
  bindableProperties,
  textWithBindings,
  convertTo
) => {
  // Decide from base64 if using JS
  const isJS = isJSBinding(textWithBindings)
  if (isJS) {
    textWithBindings = decodeJSBinding(textWithBindings)
  }

  // Determine correct regex to find bindings to replace
  const regex = isJS ? CAPTURE_VAR_INSIDE_JS : CAPTURE_VAR_INSIDE_TEMPLATE

  const convertFrom =
    convertTo === "runtimeBinding" ? "readableBinding" : "runtimeBinding"
  if (typeof textWithBindings !== "string") {
    return textWithBindings
  }
  // work from longest to shortest
  const convertFromProps = bindableProperties
    .map(el => el[convertFrom])
    .sort((a, b) => {
      return b.length - a.length
    })
  const boundValues = textWithBindings.match(regex) || []
  let result = textWithBindings
  for (let boundValue of boundValues) {
    let newBoundValue = boundValue
    // we use a search string, where any time we replace something we blank it out
    // in the search, working from longest to shortest so always use best match first
    let searchString = newBoundValue
    for (let from of convertFromProps) {
      if (isJS || shouldReplaceBinding(newBoundValue, from, convertTo)) {
        const binding = bindableProperties.find(el => el[convertFrom] === from)
        let idx
        do {
          // see if any instances of this binding exist in the search string
          idx = searchString.indexOf(from)
          if (idx !== -1) {
            let end = idx + from.length,
              searchReplace = Array(binding[convertTo].length + 1).join("*")
            // blank out parts of the search string
            searchString = replaceBetween(searchString, idx, end, searchReplace)
            newBoundValue = replaceBetween(
              newBoundValue,
              idx,
              end,
              binding[convertTo]
            )
          }
        } while (idx !== -1)
      }
    }
    result = result.replace(boundValue, newBoundValue)
  }

  // Re-encode to base64 if using JS
  if (isJS) {
    result = encodeJSBinding(result)
  }

  return result
}

/**
 * Extracts a component ID from a handlebars expression setting of
 * {{ literal [componentId] }}
 */
const extractLiteralHandlebarsID = value => {
  return value?.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
}

/**
 * Converts a readable data binding into a runtime data binding
 */
export const readableToRuntimeBinding = (
  bindableProperties,
  textWithBindings
) => {
  return bindingReplacement(
    bindableProperties,
    textWithBindings,
    "runtimeBinding"
  )
}

/**
 * Converts a runtime data binding into a readable data binding
 */
export const runtimeToReadableBinding = (
  bindableProperties,
  textWithBindings
) => {
  return bindingReplacement(
    bindableProperties,
    textWithBindings,
    "readableBinding"
  )
}
