import { firstBy } from 'thenby'
import BigNumber from 'bignumber.js'
import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes'
import { escapeRegExp } from '@baserow/modules/core/utils/string'
import { SearchModes } from '@baserow/modules/database/utils/search'
import { convertStringToMatchBackendTsvectorData } from '@baserow/modules/database/search/regexes'

/**
 * Generates a sort function based on the provided sortings.
 */
export function getRowSortFunction($registry, sortings, fields, groupBys = []) {
  let sortFunction = firstBy()
  const combined = [...groupBys, ...sortings]
  combined.forEach((sort) => {
    // Find the field that is related to the sort.
    const field = fields.find((f) => f.id === sort.field)

    if (field !== undefined) {
      const fieldName = `field_${field.id}`
      const fieldType = $registry.get('field', field.type)
      const fieldSortFunction = fieldType.getSort(fieldName, sort.order, field)
      sortFunction = sortFunction.thenBy(fieldSortFunction)
    }
  })

  sortFunction = sortFunction.thenBy((a, b) =>
    new BigNumber(a.order).minus(new BigNumber(b.order))
  )
  sortFunction = sortFunction.thenBy((a, b) => a.id - b.id)
  return sortFunction
}

/**
 * Generates a sort function for fields based on order and id.
 */
export function sortFieldsByOrderAndIdFunction(
  fieldOptions,
  primaryAlwaysFirst = false
) {
  return (a, b) => {
    if (primaryAlwaysFirst) {
      // If primary must always be first, then first by primary.
      if (a.primary > b.primary) {
        return -1
      } else if (a.primary < b.primary) {
        return 1
      }
    }

    const orderA = fieldOptions[a.id]
      ? fieldOptions[a.id].order
      : maxPossibleOrderValue
    const orderB = fieldOptions[b.id]
      ? fieldOptions[b.id].order
      : maxPossibleOrderValue

    // First by order.
    if (orderA > orderB) {
      return 1
    } else if (orderA < orderB) {
      return -1
    }

    // Then by id.
    return a.id - b.id
  }
}

/**
 * Returns only fields that are visible (not hidden).
 */
export function filterVisibleFieldsFunction(fieldOptions) {
  return (field) => {
    const exists = Object.prototype.hasOwnProperty.call(fieldOptions, field.id)
    return !exists || !fieldOptions[field.id].hidden
  }
}

/**
 * Returns only fields that are visible (not hidden).
 */
export function filterHiddenFieldsFunction(fieldOptions) {
  return (field) => {
    const exists = Object.prototype.hasOwnProperty.call(fieldOptions, field.id)
    return exists && fieldOptions[field.id].hidden
  }
}

/**
 * Represents a node in a tree structure used for grouped filters.
 * A group node is made of a filterType (AND or OR), a list of filters and a parent.
 * If the parent is null it means that it is the root node. If the parent is not
 * null it means that it is a child of the parent node, and the constructor will take care to
 * add itself to the children of the parent node, so that we can later traverse the tree
 * from the root node and check if a row matches the filters.
 */
export const TreeGroupNode = class {
  /**
   * Constructs a new TreeGroupNode.
   *
   * @param {string} filterType - The type of filter (e.g., 'AND' or 'OR').
   * @param {TreeGroupNode} [parent=null] - The parent node of this node. Null for the root node.
   */
  constructor(filterType, parent = null) {
    this.filterType = filterType
    this.parent = parent
    this.filters = []
    this.children = []
    if (parent) {
      parent.children.push(this)
    }
  }

  /**
   * Checks if this node or any of its descendants has filters.
   *
   * @returns {boolean} - True if there are filters, false otherwise.
   */
  hasFilters() {
    return this.filters.length > 0 || this.children.some((c) => c.hasFilters())
  }

  /**
   * Adds a filter object to this node list of filters.
   *
   * @param {object} filter - The filter to add.
   */
  addFilter(filter) {
    this.filters.push(filter)
  }

  /**
   * Serializes the filter tree rooted at this node. The serialized version will be in the form:
   * {
   *  filter_type: 'AND' | 'OR',
   *  filters: [
   *    {
   *       type: 'contains' | 'does_not_contain' | 'is' | 'is_not' | 'is_empty' | 'is_not_empty' | etc.,
   *       field: 1,
   *       value: 'some value'
   *     },
   *     ...
   *   ],
   *   groups: [
   *     {
   *        filter_type: 'AND' | 'OR',
   *        filters: [
   *          type: 'contains' | 'does_not_contain' | 'is' | 'is_not' | 'is_empty' | 'is_not_empty' | etc.,
   *          field: 2,
   *          value: 'some other value'
   *        },
   *        ...
   *        ],
   *       groups: [...]
   *     },
   *    ...,
   *   ],
   * }
   *
   * @returns {object} - The serialized tree.
   */
  getFiltersTreeSerialized() {
    const serialized = {
      filter_type: this.filterType,
      filters: [],
      groups: [],
    }

    for (const filter of this.filters) {
      serialized.filters.push({
        type: filter.type,
        field: filter.field,
        value: filter.value,
      })
    }

    for (const groupNode of this.children) {
      serialized.groups.push(groupNode.getFiltersTreeSerialized())
    }
    return serialized
  }

  /**
   * Determines if a given row matches the conditions of this node and its descendants.
   * This function will recursively check if the row matches the filters of this node
   * and its descendants. If the filter type of this node is 'AND' then it will return
   * true if the row matches all the filters. If the filter type of this node is 'OR'
   * then it will return true if the row matches at least one of the filters.
   *
   * @param {object} $registry - The registry containing field and filter type information.
   * @param {Array} fields - The list of fields.
   * @param {object} rowValues - The values of the row being checked.
   * @returns {boolean} - True if the row matches, false otherwise.
   */
  matches($registry, fields, rowValues) {
    for (const child of this.children) {
      const matches = child.matches($registry, fields, rowValues)
      if (this.filterType === 'AND' && !matches) {
        return false
      } else if (this.filterType === 'OR' && matches) {
        return true
      }
    }
    for (const filter of this.filters) {
      const filterValue = filter.value
      const field = fields.find((f) => f.id === filter.field)
      const fieldType = $registry.get('field', field.type)
      const viewFilterType = $registry.get('viewFilter', filter.type)
      const rowValue = rowValues[`field_${field.id}`]
      const matches = viewFilterType.matches(
        rowValue,
        filterValue,
        field,
        fieldType
      )
      if (this.filterType === 'AND' && !matches) {
        // With an `AND` filter type, the row must match all the filters, so if
        // one of the filters doesn't match we can mark it as invalid.
        return false
      } else if (this.filterType === 'OR' && matches) {
        // With an 'OR' filter type, the row only has to match one of the filters,
        // that is the case here so we can mark it as valid.
        return true
      }
    }
    if (this.filterType === 'AND') {
      // At this point with an `AND` condition the filter type matched all the
      // filters and therefore we can mark it as valid.
      return true
    } else if (this.filterType === 'OR') {
      // At this point with an `OR` condition none of the filters matched and
      // therefore we can mark it as invalid.
      return false
    }
  }
}

/**
 * Creates a tree structure from given filters and filter groups. Groups are
 * first sorted by ID because parent groups have smaller IDs since they were
 * created before their children. In this way, we ensure that when a child node
 * is added to the tree, its parent will already be present.
 * Once the tree has been created, it adds all the filters to the respective
 * groups.
 *
 * @param {string} filterType - The root filter type.
 * @param {Array} filters - The list of filters.
 * @param {Array} filterGroups - The list of filter groups.
 * @returns {TreeGroupNode} - The root of the filter tree.
 */
export const createFiltersTree = (filterType, filters, filterGroups) => {
  const rootGroup = new TreeGroupNode(filterType)
  const filterGroupsById = { '': rootGroup }
  const filterGroupsOrderedById = filterGroups
    ? [...filterGroups].sort((a, b) => a.id - b.id)
    : []

  for (const filterGroup of filterGroupsOrderedById) {
    const parent = filterGroupsById[filterGroup.parent || '']
    filterGroupsById[filterGroup.id] = new TreeGroupNode(
      filterGroup.filter_type,
      parent
    )
  }

  for (const filter of filters) {
    const filterGroupId = filter.group || ''
    const filterGroup = filterGroupsById[filterGroupId]
    filterGroup.addFilter(filter)
  }
  return rootGroup
}

/**
 * A helper function that checks if the provided row values match the provided view
 * filters. Returning false indicates that the row should not be visible for that
 * view.
 */
export const matchSearchFilters = (
  $registry,
  filterType,
  filters,
  filterGroups,
  fields,
  values
) => {
  // If there aren't any filters then it is not possible to check if the row
  // matches any of the filters, so we can mark it as valid.
  if (filters.length === 0) {
    return true
  }

  const filterTree = createFiltersTree(filterType, filters, filterGroups)
  return filterTree.matches($registry, fields, values)
}

function _fullTextSearch(registry, field, value, activeSearchTerm) {
  const searchableString = registry
    .get('field', field.type)
    .toSearchableString(field, value)
  const fixedValue = convertStringToMatchBackendTsvectorData(searchableString)
  const fixedTerm = convertStringToMatchBackendTsvectorData(activeSearchTerm)
  if (fixedTerm.length === 0) {
    return false
  } else {
    const regexMatchingWordsThatStartWithTerm =
      '(^|\\s+)' + escapeRegExp(fixedTerm)
    return !!fixedValue.match(
      new RegExp(regexMatchingWordsThatStartWithTerm, 'gu')
    )
  }
}

function _compatSearchMode(registry, field, value, activeSearchTerm) {
  return registry
    .get('field', field.type)
    .containsFilter(value, activeSearchTerm, field)
}

export function valueMatchesActiveSearchTerm(
  searchMode,
  registry,
  field,
  value,
  activeSearchTerm
) {
  if (searchMode === SearchModes.MODE_FT_WITH_COUNT) {
    return _fullTextSearch(registry, field, value, activeSearchTerm)
  } else {
    return _compatSearchMode(registry, field, value, activeSearchTerm)
  }
}

function _findFieldsInRowMatchingSearch(
  row,
  activeSearchTerm,
  fields,
  registry,
  overrides,
  searchMode
) {
  const fieldSearchMatches = new Set()
  // If the row is loading then a temporary UUID is put in its id. We don't want to
  // accidentally match against that UUID as it will be shortly replaced with its
  // real id.
  if (
    !row._.loading &&
    row.id?.toString() === (activeSearchTerm || '').trim()
  ) {
    fieldSearchMatches.add('row_id')
  }
  for (const field of fields) {
    const fieldName = `field_${field.id}`
    const rowValue =
      fieldName in overrides ? overrides[fieldName] : row[fieldName]
    if (rowValue !== undefined && rowValue !== null) {
      const doesMatch = valueMatchesActiveSearchTerm(
        searchMode,
        registry,
        field,
        rowValue,
        activeSearchTerm
      )
      if (doesMatch) {
        fieldSearchMatches.add(field.id.toString())
      }
    }
  }

  return fieldSearchMatches
}

/**
 * Helper function which calculates if a given row and which of it's fields matches a
 * given search term. The rows values can be overridden by providing an overrides
 * object containing a mapping of the field name to override to a value that will be
 * used to check for matches instead of the rows real one. The rows values will not be
 * changed.
 */
export function calculateSingleRowSearchMatches(
  row,
  activeSearchTerm,
  hideRowsNotMatchingSearch,
  fields,
  registry,
  searchMode,
  overrides = {}
) {
  const searchIsBlank = activeSearchTerm === ''
  const fieldSearchMatches = searchIsBlank
    ? new Set()
    : _findFieldsInRowMatchingSearch(
        row,
        activeSearchTerm,
        fields,
        registry,
        overrides,
        searchMode
      )

  const matchSearch =
    !hideRowsNotMatchingSearch || searchIsBlank || fieldSearchMatches.size > 0
  return { row, matchSearch, fieldSearchMatches }
}

/**
 * Returns true is the empty value of the provided field matches the active search term.
 */
export function newFieldMatchesActiveSearchTerm(
  registry,
  newField,
  activeSearchTerm
) {
  if (newField && activeSearchTerm !== '') {
    const fieldType = registry.get('field', newField.type)
    const emptyValue = fieldType.getEmptyValue(newField)

    return valueMatchesActiveSearchTerm(
      registry,
      newField,
      emptyValue,
      activeSearchTerm
    )
  }
  return false
}

export function getGroupBy(rootGetters, viewId) {
  if (rootGetters['page/view/public/getIsPublic']) {
    const view = rootGetters['view/get'](viewId)
    return view.group_bys
      .map((groupBy) => {
        return `${groupBy.order === 'DESC' ? '-' : ''}field_${groupBy.field}`
      })
      .join(',')
  } else {
    return ''
  }
}

export function getOrderBy(rootGetters, viewId) {
  if (rootGetters['page/view/public/getIsPublic']) {
    const view = rootGetters['view/get'](viewId)
    return view.sortings
      .map((sort) => {
        return `${sort.order === 'DESC' ? '-' : ''}field_${sort.field}`
      })
      .join(',')
  } else {
    return ''
  }
}

export function getFilters(rootGetters, viewId) {
  const payload = {}

  if (rootGetters['page/view/public/getIsPublic']) {
    const view = rootGetters['view/get'](viewId)

    if (!view.filters_disabled) {
      const {
        filter_type: filterType,
        filter_groups: filterGroups,
        filters,
      } = view
      const filterTree = createFiltersTree(filterType, filters, filterGroups)
      if (filterTree.hasFilters()) {
        const serializedTree = filterTree.getFiltersTreeSerialized()
        payload.filters = [JSON.stringify(serializedTree)]
      }
    }
    return payload
  }
}

/**
 * Calculates the size of a UTF-8 encoded string in bytes - computes the size
 * of a string in UTF-8 encoding and utilizes the TextEncoder API if available.
 *
 * Using TextEncoder is preferred in Modern Browsers and Node.js Supported
 * environments because it provides a more efficient and accurate way to encode
 * strings into UTF-8 bytes and directly calculate the byte size of the encoded
 * string.
 *
 * In some older web browsers or environments where TextEncoder may not be available
 * (such as SSR where certain browser APIs are absent), it falls back to a less
 * accurate method and simply returns the length of the string.
 */
export function utf8ByteSize(str) {
  // Use TextEncoder if available (modern browsers and Node.js)
  if (typeof TextEncoder !== 'undefined') {
    const encoder = new TextEncoder()
    const data = encoder.encode(str)
    return data.length
  } else {
    // Fallback for older browsers (may not be as accurate)
    return str.length
  }
}

/**
 * Limit the size of a cookie's value by removing elements from an array
 * until it fits within the maximum allowed cookie size.
 */
export function fitInCookie(name, list) {
  const result = []
  for (let i = list.length - 1; i >= 0; i--) {
    result.unshift(list[i])
    const serialized = encodeURIComponent(JSON.stringify(result))
    if (utf8ByteSize(serialized) > 4096) {
      result.shift() // Remove the last added item as it caused the size to exceed the limit
      break
    }
  }
  return result
}

/**
 * Return the view that has been visited most recently or the first
 * available one that is capable of displaying the provided row data if required.
 * If no view is available that can display the row data, return undefined.
 */
export function getDefaultView(app, store, workspaceId, showRowModal) {
  // Put the most recently visited one first in the list.
  const defaultView = store.getters['view/default']
  const allViews = store.getters['view/getAllOrdered']
  const views = defaultView ? [defaultView, ...allViews] : allViews

  return views.find((view) => {
    const viewType = app.$registry.get('view', view.type)
    if (viewType.isDeactivated(workspaceId)) {
      return false
    }
    // Ensure that the view can display the row data if required.
    return showRowModal ? viewType.canShowRowModal() : true
  })
}

/*
 * Extracts the metadata from the provided data to populate the row.
 */
export function extractRowMetadata(data, rowId) {
  const metadata = data.row_metadata || {}
  return metadata[rowId] || {}
}