<?php
/*
 +--------------------------------------------------------------------+
 | Copyright CiviCRM LLC. All rights reserved.                        |
 |                                                                    |
 | This work is published under the GNU AGPLv3 license with some      |
 | permitted exceptions and without any warranty. For full license    |
 | and copyright information, see https://civicrm.org/licensing       |
 +--------------------------------------------------------------------+
 */

/**
 * CiviCRM APIv3 utility functions.
 *
 * @package CiviCRM_APIv3
 */

/**
 * Initialize CiviCRM - should be run at the start of each API function.
 */
function _civicrm_api3_initialize() {
  require_once 'CRM/Core/ClassLoader.php';
  CRM_Core_ClassLoader::singleton()->register();
  CRM_Core_Config::singleton();
}

/**
 * Wrapper Function for civicrm_verify_mandatory to make it simple to pass either / or fields for checking.
 *
 * @param array $params
 *   Array of fields to check.
 * @param array $daoName
 *   String DAO to check for required fields (create functions only).
 * @param array $keyoptions
 *   List of required fields options. One of the options is required.
 *
 * @throws \CRM_Core_Exception
 */
function civicrm_api3_verify_one_mandatory($params, $daoName = NULL, $keyoptions = []) {
  $keys = [[]];
  foreach ($keyoptions as $key) {
    $keys[0][] = $key;
  }
  civicrm_api3_verify_mandatory($params, $daoName, $keys);
}

/**
 * Check mandatory fields are included.
 *
 * @param array $params
 *   Array of fields to check.
 * @param array $daoName
 *   String DAO to check for required fields (create functions only).
 * @param array $keys
 *   List of required fields. A value can be an array denoting that either this or that is required.
 * @param bool $verifyDAO
 *
 * @throws \CRM_Core_Exception
 */
function civicrm_api3_verify_mandatory($params, $daoName = NULL, $keys = [], $verifyDAO = TRUE) {
  $unmatched = [];

  if (!empty($params['id'])) {
    $keys = ['version'];
  }
  else {
    if (!in_array('version', $keys)) {
      // required from v3 onwards
      $keys[] = 'version';
    }
  }
  foreach ($keys as $key) {
    if (is_array($key)) {
      $match = 0;
      $optionset = [];
      foreach ($key as $subkey) {
        if (!array_key_exists($subkey, $params) || empty($params[$subkey])) {
          $optionset[] = $subkey;
        }
        else {
          // As long as there is one match we don't need to return anything.
          $match = 1;
        }
      }
      if (empty($match) && !empty($optionset)) {
        $unmatched[] = "one of (" . implode(", ", $optionset) . ")";
      }
    }
    else {
      // Disallow empty values except for the number zero.
      // TODO: create a utility for this since it's needed in many places.
      if (!array_key_exists($key, $params) || (empty($params[$key]) && $params[$key] !== 0.0 && $params[$key] !== 0 && $params[$key] !== '0')) {
        $unmatched[] = $key;
      }
    }
  }
  if (!empty($unmatched)) {
    throw new CRM_Core_Exception('Mandatory key(s) missing from params array: ' . implode(", ", $unmatched), 'mandatory_missing', ["fields" => $unmatched]);
  }
}

/**
 * Create error array.
 *
 * @param string $msg
 * @param array $data
 *
 * @return array
 */
function civicrm_api3_create_error($msg, $data = []) {
  $data['is_error'] = 1;
  $data['error_message'] = $msg;

  // we will show sql to privileged user only (not sure of a specific
  // security hole here but seems sensible - perhaps should apply to the trace as well?)
  if (isset($data['sql'])) {
    if (CRM_Core_Permission::check('Administer CiviCRM') || CIVICRM_UF === 'UnitTests') {
      // Isn't this redundant?
      $data['debug_information'] = $data['sql'];
    }
    else {
      unset($data['sql']);
    }
  }
  return $data;
}

/**
 * Format array in result output style.
 *
 * @param array|int $values values generated by API operation (the result)
 * @param array $params
 *   Parameters passed into API call.
 * @param string $entity
 *   The entity being acted on.
 * @param string $action
 *   The action passed to the API.
 * @param object $dao
 *   DAO object to be freed here.
 * @param array $extraReturnValues
 *   Additional values to be added to top level of result array(.
 *   - this param is currently used for legacy behaviour support
 *
 * @return array
 * @throws \CRM_Core_Exception
 */
function civicrm_api3_create_success($values = 1, $params = [], $entity = NULL, $action = NULL, &$dao = NULL, $extraReturnValues = []) {
  $result = [];
  $lowercase_entity = _civicrm_api_get_entity_name_from_camel($entity);
  // TODO: This shouldn't be necessary but this fn sometimes gets called with lowercase entity
  $entity = _civicrm_api_get_camel_name($entity);
  $result['is_error'] = 0;
  //lets set the ['id'] field if it's not set & we know what the entity is
  if (is_array($values) && $entity && $action !== 'getfields') {
    foreach ($values as $key => $item) {
      if (empty($item['id']) && !empty($item[$lowercase_entity . '_id'])) {
        $values[$key]['id'] = $item[$lowercase_entity . '_id'];
      }
      if (!empty($item['financial_type_id'])) {
        // 4.3 legacy handling.
        $values[$key]['contribution_type_id'] = $item['financial_type_id'];
      }
      if (!empty($item['contribution_cancel_date'])) {
        // 5.16 legacy handling.
        $values[$key]['cancel_date'] = $item['contribution_cancel_date'];
      }
      if (!empty($item['next_sched_contribution_date'])) {
        // 4.4 legacy handling
        $values[$key]['next_sched_contribution'] = $item['next_sched_contribution_date'];
      }
    }
  }

  if (is_array($params) && $entity && !empty($params['debug'])) {
    if (is_string($action) && $action !== 'getfields') {
      $apiFields = civicrm_api($entity, 'getfields', ['version' => 3, 'action' => $action] + $params);
    }
    elseif ($action !== 'getfields') {
      $apiFields = civicrm_api($entity, 'getfields', ['version' => 3] + $params);
    }
    else {
      $apiFields = FALSE;
    }

    $allFields = [];
    if ($action !== 'getfields' && isset($apiFields['values']) && is_array($apiFields['values'])) {
      $allFields = array_keys($apiFields['values']);
    }
    $paramFields = array_keys($params);
    $undefined = array_diff($paramFields, $allFields, array_keys($_COOKIE), [
      'action',
      'entity',
      'debug',
      'version',
      'check_permissions',
      'IDS_request_uri',
      'IDS_user_agent',
      'return',
      'sequential',
      'rowCount',
      'option_offset',
      'option_limit',
      'custom',
      'option_sort',
      'options',
      'prettyprint',
    ]);
    if ($undefined) {
      $result['undefined_fields'] = array_merge($undefined);
    }
  }

  $result['version'] = 3;
  if (is_array($values)) {
    $result['count'] = (int) count($values);

    // Convert value-separated strings to array
    if ($action !== 'getfields') {
      _civicrm_api3_separate_values($values);
    }

    if ($result['count'] == 1) {
      [$result['id']] = array_keys($values);
    }
    elseif (!empty($values['id']) && is_int($values['id'])) {
      $result['id'] = $values['id'];
    }
  }
  else {
    $result['count'] = !empty($values) ? 1 : 0;
  }

  if (is_array($values) && isset($params['sequential']) &&
    $params['sequential'] == 1
  ) {
    $result['values'] = array_values($values);
  }
  else {
    $result['values'] = $values;
  }
  if (!empty($params['options']['metadata'])) {
    // We've made metadata an array but only supporting 'fields' atm.
    if (in_array('fields', (array) $params['options']['metadata']) && $action !== 'getfields') {
      $fields = civicrm_api3($entity, 'getfields', [
        'action' => substr($action, 0, 3) == 'get' ? 'get' : 'create',
      ]);
      $result['metadata']['fields'] = $fields['values'];
    }
  }
  // Report deprecations.
  $deprecated = _civicrm_api3_deprecation_check($entity, $result);
  // Always report "setvalue" action as deprecated.
  if (!is_string($deprecated) && ($action === 'getactions' || $action === 'setvalue')) {
    $deprecated = ((array) $deprecated) + ['setvalue' => 'The "setvalue" action is deprecated. Use "create" with an id instead.'];
  }
  // Always report "update" action as deprecated.
  if (!is_string($deprecated) && ($action === 'getactions' || $action === 'update')) {
    $deprecated = ((array) $deprecated) + ['update' => 'The "update" action is deprecated. Use "create" with an id instead.'];
  }
  if ($deprecated) {
    // Metadata-level deprecations or wholesale entity deprecations.
    if ($entity === 'Entity' || $action === 'getactions' || is_string($deprecated)) {
      $result['deprecated'] = $deprecated;
    }
    // Action-specific deprecations
    elseif (!empty($deprecated[$action])) {
      $result['deprecated'] = $deprecated[$action];
    }
  }
  return array_merge($result, $extraReturnValues);
}

/**
 * Load the DAO of the entity.
 *
 * @param string $entity
 *
 * @return bool
 */
function _civicrm_api3_load_DAO($entity) {
  $dao = _civicrm_api3_get_DAO($entity);
  if (empty($dao)) {
    return FALSE;
  }
  $d = new $dao();
  return $d;
}

/**
 * Return the DAO of the function or Entity.
 *
 * @param string $name
 *   Either a function of the api (civicrm_{entity}_create or the entity name.
 *   return the DAO name to manipulate this function
 *   eg. "civicrm_api3_contact_create" or "Contact" will return "CRM_Contact_BAO_Contact"
 *
 * @return CRM_Core_DAO|string
 */
function _civicrm_api3_get_DAO($name) {
  if (str_contains($name, 'civicrm_api3')) {
    $last = strrpos($name, '_');
    // len ('civicrm_api3_') == 13
    $name = substr($name, 13, $last - 13);
  }

  $name = _civicrm_api_get_camel_name($name);

  if ($name === 'Individual' || $name === 'Household' || $name === 'Organization') {
    $name = 'Contact';
  }

  // hack to deal with incorrectly named BAO/DAO - see CRM-10859

  if ($name === 'AclRole' || $name === 'ACLRole') {
    return 'CRM_ACL_DAO_ACLEntityRole';
  }
  // Entity was renamed to CRM_Dedupe_DAO_DedupeRule for APIv4
  if ($name === 'Rule') {
    return 'CRM_Dedupe_DAO_DedupeRule';
  }
  // Entity was renamed to CRM_Dedupe_DAO_DedupeRuleGroup for APIv4
  if ($name === 'RuleGroup') {
    return 'CRM_Dedupe_DAO_DedupeRuleGroup';
  }
  // Entity was renamed to CRM_Dedupe_DAO_DedupeException for APIv4
  if ($name === 'Exception') {
    return 'CRM_Dedupe_DAO_DedupeException';
  }
  // FIXME: DAO names should follow CamelCase convention
  if ($name === 'Im' || $name === 'Acl' || $name === 'Pcp') {
    $name = strtoupper($name);
  }

  $dao = CRM_Core_DAO_AllCoreTables::getDAONameForEntity($name);
  if ($dao || !$name) {
    return $dao;
  }

  // Really weird apis can declare their own DAO name. Not sure if this is a good idea...
  if (file_exists("api/v3/$name.php")) {
    include_once "api/v3/$name.php";
  }

  $daoFn = '_civicrm_api3_' . _civicrm_api_get_entity_name_from_camel($name) . '_DAO';
  if (function_exists($daoFn)) {
    return $daoFn();
  }

  return NULL;
}

/**
 * Return the BAO name of the function or Entity.
 *
 * @param string $name
 *   Is either a function of the api (civicrm_{entity}_create or the entity name.
 *   return the DAO name to manipulate this function
 *   eg. "civicrm_contact_create" or "Contact" will return "CRM_Contact_BAO_Contact"
 *
 * @return string|null
 */
function _civicrm_api3_get_BAO($name) {
  // FIXME: DAO should be renamed CRM_Badge_DAO_BadgeLayout
  if ($name === 'PrintLabel') {
    return 'CRM_Badge_BAO_Layout';
  }
  if ($name === 'Order') {
    // Order basically maps to contribution at the top level but
    // has enhanced access to other entities.
    $name = 'Contribution';
  }
  if ($name === 'Dedupe') {
    // Dedupe is a pseudoentity for PrevNextCache - but accessing dedupe related info
    // not the other cache info like search results (which could in fact be in Redis or another cache engine)
    $name = 'PrevNextCache';
  }
  if ($name === 'Payment') {
    $name = 'FinancialTrxn';
  }
  $dao = _civicrm_api3_get_DAO($name);
  if (!$dao) {
    return NULL;
  }
  $bao = str_replace("DAO", "BAO", $dao);
  $file = strtr($bao, '_', '/') . '.php';
  // Check if this entity actually has a BAO. Fall back on the DAO if not.
  return stream_resolve_include_path($file) ? $bao : $dao;
}

/**
 * Recursive function to explode value-separated strings into arrays.
 *
 * @param array $values
 */
function _civicrm_api3_separate_values(&$values) {
  $sp = CRM_Core_DAO::VALUE_SEPARATOR;
  foreach ($values as $key => & $value) {
    if (is_array($value)) {
      _civicrm_api3_separate_values($value);
    }
    elseif (is_string($value)) {
      // This is to honor the way case API was originally written.
      if ($key === 'case_type_id') {
        $value = trim(str_replace($sp, ',', $value), ',');
      }
      elseif (str_contains($value, $sp)) {
        $value = explode($sp, trim($value, $sp));
      }
    }
  }
}

/**
 * This is a legacy wrapper for api_store_values.
 *
 * It checks suitable fields using getfields rather than DAO->fields.
 *
 * Getfields has handling for how to deal with unique names which dao->fields doesn't
 *
 * Note this is used by BAO type create functions - eg. contribution
 *
 * @param string $entity
 * @param array $params
 * @param array $values
 */
function _civicrm_api3_filter_fields_for_bao($entity, &$params, &$values) {
  $fields = civicrm_api($entity, 'getfields', ['version' => 3, 'action' => 'create']);
  $fields = $fields['values'];
  _civicrm_api3_store_values($fields, $params, $values);
}

/**
 * Copy values (other than id) from params to values if they exist in fields.
 *
 * @param array $fields
 * @param array $params
 * @param array $values
 *
 * @return bool
 */
function _civicrm_api3_store_values(array $fields, array $params, &$values): bool {
  $valueFound = FALSE;

  $keys = array_intersect_key($params, $fields);
  foreach ($keys as $name => $value) {
    if ($name !== 'id') {
      $values[$name] = $value;
      $valueFound = TRUE;
    }
  }
  return $valueFound;
}

/**
 * Get function for query object api.
 *
 * The API supports 2 types of get request. The more complex uses the BAO query object.
 *  This is a generic function for those functions that call it
 *
 *  At the moment only called by contact we should extend to contribution &
 *  others that use the query object. Note that this function passes permission information in.
 *  The others don't
 *
 * Ideally this would be merged with _civicrm_get_query_object but we need to resolve differences in what the
 * 2 variants call
 *
 * @param string $entity
 * @param array $params
 *   As passed into api get or getcount function.
 * @param array $additional_options
 *   Array of options (so we can modify the filter).
 * @param bool $getCount
 *   Are we just after the count.
 * @param int $mode
 *   This basically correlates to the component.
 * @param null|array $defaultReturnProperties
 *   Default return properties for the entity
 *  (used if return not set - but don't do that - set return!).
 *
 * @return array
 * @throws CRM_Core_Exception
 */
function _civicrm_api3_get_using_query_object($entity, $params, $additional_options = [], $getCount = NULL, $mode = 1, $defaultReturnProperties = NULL) {
  $lowercase_entity = _civicrm_api_get_entity_name_from_camel($entity);
  // Convert id to e.g. contact_id
  if (empty($params[$lowercase_entity . '_id']) && isset($params['id'])) {
    $params[$lowercase_entity . '_id'] = $params['id'];
  }
  unset($params['id']);

  $options = _civicrm_api3_get_options_from_params($params, TRUE);

  $inputParams = array_merge(
    $options['input_params'] ?? [],
    $additional_options['input_params'] ?? []
  );
  $returnProperties = array_merge(
    $options['return'] ?? [],
    $additional_options['return'] ?? []
  );
  if (empty($returnProperties)) {
    $returnProperties = $defaultReturnProperties;
  }
  if (!empty($params['check_permissions'])) {
    // we will filter query object against getfields
    $fields = civicrm_api($entity, 'getfields', ['version' => 3, 'action' => 'get']);
    // we need to add this in as earlier in this function 'id' was unset in favour of $entity_id
    $fields['values'][$lowercase_entity . '_id'] = [];
    $varsToFilter = ['returnProperties', 'inputParams'];
    foreach ($varsToFilter as $varToFilter) {
      if (!is_array($$varToFilter)) {
        continue;
      }
      //I was going to throw an exception rather than silently filter out - but
      //would need to diff out of exceptions arr other keys like 'options', 'return', 'api. etcetc
      //so we are silently ignoring parts of their request
      //$exceptionsArr = array_diff(array_keys($$varToFilter), array_keys($fields['values']));
      $$varToFilter = array_intersect_key($$varToFilter, $fields['values']);
    }
  }
  $options = array_merge($options, $additional_options);
  $sort             = $options['sort'] ?? NULL;
  $offset             = $options['offset'] ?? NULL;
  $limit             = $options['limit'] ?? NULL;
  $smartGroupCache  = $params['smartGroupCache'] ?? NULL;

  if ($getCount) {
    $limit = NULL;
    $returnProperties = NULL;
  }

  if (substr(($sort ?? ''), 0, 2) == 'id') {
    $sort = $lowercase_entity . "_" . $sort;
  }

  $newParams = CRM_Contact_BAO_Query::convertFormValues($inputParams);

  $skipPermissions = !empty($params['check_permissions']) ? 0 : 1;

  [$entities] = CRM_Contact_BAO_Query::apiQuery(
    $newParams,
    $returnProperties,
    NULL,
    $sort,
    $offset,
    $limit,
    $smartGroupCache,
    $getCount,
    $skipPermissions,
    $mode,
    $entity,
    TRUE
  );

  return $entities;
}

/**
 * Get dao query object based on input params.
 *
 * Ideally this would be merged with _civicrm_get_using_query_object but we need to resolve differences in what the
 * 2 variants call
 *
 * @param array $params
 * @param string $mode
 * @param string $entity
 *
 * @return array
 *   [CRM_Core_DAO|CRM_Contact_BAO_Query]
 */
function _civicrm_api3_get_query_object($params, $mode, $entity) {
  $options = _civicrm_api3_get_options_from_params($params, TRUE, $entity, 'get');
  $sort = $options['sort'] ?? NULL;
  $offset = $options['offset'] ?? NULL;
  $rowCount = $options['limit'] ?? NULL;
  $inputParams = $options['input_params'] ?? [];
  $returnProperties = $options['return'] ?? NULL;
  if (empty($returnProperties)) {
    $returnProperties = CRM_Contribute_BAO_Query::defaultReturnProperties($mode);
  }

  $newParams = CRM_Contact_BAO_Query::convertFormValues($inputParams, 0, FALSE, $entity);
  $query = new CRM_Contact_BAO_Query($newParams, $returnProperties, NULL,
    FALSE, FALSE, $mode,
    empty($params['check_permissions']),
    TRUE, TRUE, NULL, 'AND', 'NULL', TRUE
  );
  [$select, $from, $where, $having] = $query->query();

  $sql = "$select $from $where $having";

  if (!empty($sort)) {
    $sort = CRM_Utils_Type::escape($sort, 'MysqlOrderBy');
    $sql .= " ORDER BY $sort ";
  }
  if (!empty($rowCount)) {
    $sql .= " LIMIT $offset, $rowCount ";
  }
  $dao = CRM_Core_DAO::executeQuery($sql);
  return [$dao, $query];
}

/**
 * Function transfers the filters being passed into the DAO onto the params object.
 *
 * @deprecated DAO based retrieval is being phased out.
 *
 * @param CRM_Core_DAO $dao
 * @param array $params
 * @param bool $unique
 * @param array $extraSql
 *   API specific queries eg for event isCurrent would be converted to
 *   $extraSql['where'] = array('civicrm_event' => array('(start_date >= CURDATE() || end_date >= CURDATE())'));
 *
 * @throws CRM_Core_Exception
 * @throws Exception
 */
function _civicrm_api3_dao_set_filter(&$dao, $params, $unique = TRUE, $extraSql = []) {
  $entity = _civicrm_api_get_entity_name_from_dao($dao);
  $lowercase_entity = _civicrm_api_get_entity_name_from_camel($entity);
  if (!empty($params[$lowercase_entity . "_id"]) && empty($params['id'])) {
    //if entity_id is set then treat it as ID (will be overridden by id if set)
    $params['id'] = $params[$lowercase_entity . "_id"];
  }
  $allfields = _civicrm_api3_build_fields_array($dao, $unique);
  $fields = array_intersect(array_keys($allfields), array_keys($params));

  $options = _civicrm_api3_get_options_from_params($params);
  //apply options like sort
  _civicrm_api3_apply_options_to_dao($params, $dao, $entity);

  //accept filters like filter.activity_date_time_high
  // std is now 'filters' => ..
  if (str_contains(implode(',', array_keys($params)), 'filter')) {
    if (isset($params['filters']) && is_array($params['filters'])) {
      foreach ($params['filters'] as $paramkey => $paramvalue) {
        _civicrm_api3_apply_filters_to_dao($paramkey, $paramvalue, $dao);
      }
    }
    else {
      foreach ($params as $paramkey => $paramvalue) {
        if (str_contains($paramkey, 'filter')) {
          _civicrm_api3_apply_filters_to_dao(substr($paramkey, 7), $paramvalue, $dao);
        }
      }
    }
  }
  if (!$fields) {
    $fields = [];
  }

  foreach ($fields as $field) {
    if (is_array($params[$field])) {
      //get the actual fieldname from db
      $fieldName = $allfields[$field]['name'];
      $where = CRM_Core_DAO::createSqlFilter($fieldName, $params[$field], 'String');
      if (!empty($where)) {
        $dao->whereAdd($where);
      }
    }
    else {
      if ($unique) {
        $daoFieldName = $allfields[$field]['name'];
        if (empty($daoFieldName)) {
          throw new CRM_Core_Exception("Failed to determine field name for \"$field\"");
        }
        $dao->{$daoFieldName} = $params[$field];
      }
      else {
        $dao->$field = $params[$field];
      }
    }
  }
  if (!empty($extraSql['where'])) {
    foreach ($extraSql['where'] as $table => $sqlWhere) {
      foreach ($sqlWhere as $where) {
        $dao->whereAdd($where);
      }
    }
  }
  if (!empty($options['return']) && is_array($options['return']) && empty($options['is_count'])) {
    $dao->selectAdd();
    // Ensure 'id' is included.
    $options['return']['id'] = TRUE;
    $allfields = _civicrm_api3_get_unique_name_array($dao);
    $returnMatched = array_intersect(array_keys($options['return']), $allfields);
    foreach ($returnMatched as $returnValue) {
      $dao->selectAdd($returnValue);
    }

    // Not already matched on the field names.
    $unmatchedFields = array_diff(
      array_keys($options['return']),
      $returnMatched
    );

    $returnUniqueMatched = array_intersect(
      $unmatchedFields,
      // But a match for the field keys.
      array_flip($allfields)
    );
    foreach ($returnUniqueMatched as $uniqueVal) {
      $dao->selectAdd($allfields[$uniqueVal]);
    }
  }
  $dao->setApiFilter($params);
}

/**
 * Apply filters (e.g. high, low) to DAO object (prior to find).
 *
 * @param string $filterField
 *   Field name of filter.
 * @param string $filterValue
 *   Field value of filter.
 * @param object $dao
 *   DAO object.
 */
function _civicrm_api3_apply_filters_to_dao($filterField, $filterValue, &$dao) {
  if (str_contains($filterField, 'high')) {
    $fieldName = substr($filterField, 0, -5);
    $dao->whereAdd("($fieldName <= $filterValue )");
  }
  if (str_contains($filterField, 'low')) {
    $fieldName = substr($filterField, 0, -4);
    $dao->whereAdd("($fieldName >= $filterValue )");
  }
  if ($filterField == 'is_current' && $filterValue == 1) {
    $todayStart = date('Ymd000000', strtotime('now'));
    $todayEnd = date('Ymd235959', strtotime('now'));
    $dao->whereAdd("(start_date <= '$todayStart' OR start_date IS NULL) AND (end_date >= '$todayEnd' OR end_date IS NULL)");
    if (property_exists($dao, 'is_active')) {
      $dao->whereAdd('is_active = 1');
    }
  }
}

/**
 * Get sort, limit etc options from the params - supporting old & new formats.
 *
 * Get returnProperties for legacy
 *
 * @param array $params
 *   Params array as passed into civicrm_api.
 * @param bool $queryObject
 *   Is this supporting a queryObject api (e.g contact) - if so we support more options.
 *   for legacy report & return a unique fields array
 *
 * @param string $entity
 * @param string $action
 *
 * @throws CRM_Core_Exception
 * @return array
 *   options extracted from params
 */
function _civicrm_api3_get_options_from_params($params, $queryObject = FALSE, $entity = '', $action = '') {
  $lowercase_entity = _civicrm_api_get_entity_name_from_camel($entity);
  $is_count = FALSE;

  // dear PHP thought it would be a good idea to transform a.b into a_b in the get/post
  $sort = $params['option_sort'] ?? $params['option.sort'] ?? $params['sort'] ?? 0;
  $offset = $params['option_offset'] ?? $params['option.offset'] ?? $params['offset'] ?? 0;

  $limit = $params['rowCount'] ?? 25;
  $limit = $params['option.limit'] ?? $limit;
  $limit = $params['option_limit'] ?? $limit;

  if (isset($params['options']) && is_array($params['options'])) {
    // is count is set by generic getcount not user
    $is_count = $params['options']['is_count'] ?? FALSE;
    $offset = $params['options']['offset'] ?? $offset;
    $limit = $params['options']['limit'] ?? $limit;
    $sort = $params['options']['sort'] ?? $sort;
  }

  $returnProperties = [];
  // handle the format return =sort_name,display_name...
  if (array_key_exists('return', $params)) {
    if (is_array($params['return'])) {
      $returnProperties = array_fill_keys($params['return'], 1);
    }
    else {
      $returnProperties = explode(',', str_replace(' ', '', $params['return']));
      $returnProperties = array_fill_keys($returnProperties, 1);
    }
  }
  if ($entity && $action == 'get') {
    if (!empty($returnProperties['id'])) {
      $returnProperties[$lowercase_entity . '_id'] = 1;
      unset($returnProperties['id']);
    }
  }

  $options = [
    'offset' => CRM_Utils_Rule::integer($offset) ? $offset : NULL,
    'limit' => (!$is_count && CRM_Utils_Rule::integer($limit)) ? $limit : NULL,
    'is_count' => $is_count,
    'return' => !empty($returnProperties) ? $returnProperties : [],
  ];

  $finalSort = [];
  $options['sort'] = NULL;
  if (!empty($sort)) {
    if (!is_array($sort)) {
      $sort = array_map('trim', explode(',', $sort));
    }
    foreach ($sort as $s) {
      if ($s === '(1)' || CRM_Utils_Rule::mysqlOrderBy($s)) {
        if ($entity && $action === 'get') {
          switch (trim(strtolower($s))) {
            case 'id':
            case 'id desc':
            case 'id asc':
              $s = str_replace('id', $lowercase_entity . '_id', $s);
          }
        }
        $finalSort[] = $s;
      }
      else {
        throw new CRM_Core_Exception("Unknown field specified for sort. Cannot order by '$s'");
      }
    }
    $options['sort'] = implode(', ', $finalSort);
  }

  if ($options['sort'] && stristr($options['sort'], 'SELECT')) {
    throw new CRM_Core_Exception('invalid string in sort options');
  }

  if (!$queryObject) {
    return $options;
  }
  //here comes the legacy support for $returnProperties, $inputParams e.g for contat_get
  // if the query object is being used this should be used
  $inputParams = [];
  $legacyreturnProperties = [];
  $otherVars = [
    'sort', 'offset', 'rowCount', 'options', 'return',
    'version', 'prettyprint', 'check_permissions', 'sequential',
  ];
  foreach ($params as $n => $v) {
    if (substr($n, 0, 7) === 'return.') {
      $legacyreturnProperties[substr($n, 7)] = $v;
    }
    elseif ($n === 'id') {
      $inputParams[$lowercase_entity . '_id'] = $v;
    }
    elseif (!in_array($n, $otherVars)) {
      $inputParams[$n] = $v;
      if ($v && !is_array($v) && stristr($v, 'SELECT')) {
        throw new CRM_Core_Exception('invalid string');
      }
    }
  }
  $options['return'] = array_merge($returnProperties, $legacyreturnProperties);
  $options['input_params'] = $inputParams;
  return $options;
}

/**
 * Apply options (e.g. sort, limit, order by) to DAO object (prior to find).
 *
 * @param array $params
 *   Params array as passed into civicrm_api.
 * @param object $dao
 *   DAO object.
 * @param string $entity
 *
 * @throws \CRM_Core_Exception
 */
function _civicrm_api3_apply_options_to_dao(&$params, &$dao, $entity) {

  $options = _civicrm_api3_get_options_from_params($params, FALSE, $entity);
  if (!$options['is_count']) {
    if (!empty($options['limit'])) {
      $dao->limit((int) $options['offset'], (int) $options['limit']);
    }
    if (!empty($options['sort'])) {
      $options['sort'] = CRM_Utils_Type::escape($options['sort'], 'MysqlOrderBy');
      $dao->orderBy($options['sort']);
    }
  }
}

/**
 * Build fields array.
 *
 * This is the array of fields as it relates to the given DAO
 * returns unique fields as keys by default but if set but can return by DB fields
 *
 * @param CRM_Core_DAO $bao
 * @param bool $unique
 *
 * @return array
 */
function _civicrm_api3_build_fields_array(&$bao, $unique = TRUE) {
  $fields = $bao->fields();
  if ($unique) {
    if (empty($fields['id'])) {
      $lowercase_entity = _civicrm_api_get_entity_name_from_camel(_civicrm_api_get_entity_name_from_dao($bao));
      if (isset($fields[$lowercase_entity . '_id'])) {
        $fields['id'] = $fields[$lowercase_entity . '_id'];
        unset($fields[$lowercase_entity . '_id']);
      }
    }
    return $fields;
  }

  foreach ($fields as $field) {
    $dbFields[$field['name']] = $field;
  }
  return $dbFields;
}

/**
 * Build fields array.
 *
 * This is the array of fields as it relates to the given DAO
 * returns unique fields as keys by default but if set but can return by DB fields
 *
 * @param CRM_Core_DAO $bao
 *
 * @return array
 */
function _civicrm_api3_get_unique_name_array(&$bao) {
  $fields = $bao->fields();
  foreach ($fields as $field => $values) {
    $uniqueFields[$field] = $values['name'] ?? $field;
  }
  return $uniqueFields;
}

/**
 * Converts an DAO object to an array.
 *
 * @param CRM_Core_DAO $dao
 *   Object to convert.
 * @param array $params
 * @param bool $uniqueFields
 * @param string $entity
 * @param bool $autoFind
 *
 * @return array
 *
 * @throws \CRM_Core_Exception
 *
 * @deprecated - DAO based retrieval is being phased out.
 *
 */
function _civicrm_api3_dao_to_array($dao, $params = NULL, $uniqueFields = TRUE, $entity = "", $autoFind = TRUE) {
  $result = [];
  if (isset($params['options']) && !empty($params['options']['is_count'])) {
    return $dao->count();
  }
  if (empty($dao)) {
    return [];
  }
  if ($autoFind && !$dao->find()) {
    return [];
  }

  if (isset($dao->count)) {
    return $dao->count;
  }

  $fields = array_keys(_civicrm_api3_build_fields_array($dao, FALSE));
  while ($dao->fetch()) {
    $tmp = [];
    foreach ($fields as $key) {
      if (property_exists($dao, $key)) {
        // not sure on that one
        if ($dao->$key !== NULL) {
          $tmp[$key] = $dao->$key;
        }
      }
    }
    $result[$dao->id] = $tmp;

    if (_civicrm_api3_custom_fields_are_required($entity, $params)) {
      _civicrm_api3_custom_data_get($result[$dao->id], $params['check_permissions'], $entity, $dao->id);
    }
  }

  return $result;
}

/**
 * Determine if custom fields need to be retrieved.
 *
 * We currently retrieve all custom fields or none at this level so if we know the entity
 * && it can take custom fields & there is the string 'custom' in their return request we get them all, they are filtered on the way out
 *
 * @param string $entity
 *   Entity name in CamelCase.
 * @param array $params
 *
 * @return bool
 * @throws \CRM_Core_Exception
 *
 * @todo filter so only required fields are queried
 */
function _civicrm_api3_custom_fields_are_required($entity, $params) {
  if (!array_key_exists($entity, CRM_Core_BAO_CustomQuery::$extendsMap)) {
    return FALSE;
  }
  $options = _civicrm_api3_get_options_from_params($params);
  // We check for possibility of 'custom' => 1 as well as specific custom fields.
  $returnString = implode('', $options['return']) . implode('', array_keys($options['return']));
  if (stristr($returnString, 'custom')) {
    return TRUE;
  }
}

/**
 * Converts an object to an array.
 *
 * @param object $dao
 *   (reference) object to convert.
 * @param array $values
 *   (reference) array.
 * @param array|bool $uniqueFields
 */
function _civicrm_api3_object_to_array(&$dao, &$values, $uniqueFields = FALSE) {

  $fields = _civicrm_api3_build_fields_array($dao, $uniqueFields);
  foreach ($fields as $key => $value) {
    if (property_exists($dao, $key)) {
      $values[$key] = $dao->$key ?? NULL;
    }
  }
}

/**
 * Wrapper for _civicrm_object_to_array when api supports unique fields.
 *
 * @param CRM_Core_DAO $dao
 * @param array $values
 *
 * @return array
 */
function _civicrm_api3_object_to_array_unique_fields(&$dao, &$values) {
  return _civicrm_api3_object_to_array($dao, $values, TRUE);
}

/**
 * Format custom parameters.
 *
 * @param array $params
 * @param array $values
 * @param string $extends
 *   Entity that this custom field extends (e.g. contribution, event, contact).
 * @param string $entityId
 *   ID of entity per $extends.
 */
function _civicrm_api3_custom_format_params($params, &$values, $extends, $entityId = NULL) {
  if (!empty($params['custom']) && empty($params['check_permissions'])) {
    // The Import class does the formatting first - ideally it wouldn't but this early return
    // provides transitional support.
    return;
  }
  $values['custom'] = [];
  $checkCheckBoxField = FALSE;
  $entity = $extends;
  if (in_array($extends, CRM_Contact_BAO_ContactType::basicTypes(TRUE), TRUE)) {
    $entity = 'Contact';
  }

  $fields = civicrm_api($entity, 'getfields', ['version' => 3, 'action' => 'create']);
  if (!$fields['is_error']) {
    // not sure if fields could be error - maybe change to using civicrm_api3 wrapper later - this is conservative
    $fields = $fields['values'];
    $checkCheckBoxField = TRUE;
  }

  foreach ($params as $key => $value) {
    [$customFieldID, $customValueID] = CRM_Core_BAO_CustomField::getKeyID($key, TRUE);
    if ($customFieldID && (!is_null($value))) {
      if ($checkCheckBoxField && isset($fields['custom_' . $customFieldID]) && $fields['custom_' . $customFieldID]['html_type'] == 'CheckBox') {
        formatCheckBoxField($value, 'custom_' . $customFieldID, $entity);
      }

      CRM_Core_BAO_CustomField::formatCustomField($customFieldID, $values['custom'],
        $value, $extends, $customValueID, $entityId, FALSE, !empty($params['check_permissions']), TRUE
      );
    }
  }
}

/**
 * Format parameters for create action.
 *
 * @param array $params
 * @param string $entity
 */
function _civicrm_api3_format_params_for_create(&$params, $entity) {
  if (!$entity) {
    return;
  }
  $entity = CRM_Core_DAO_AllCoreTables::convertEntityNameToCamel($entity);
  $nonGenericEntities = array_merge(['Contact'], CRM_Contact_BAO_ContactType::basicTypes(TRUE));

  $customFieldEntities = array_diff_key(CRM_Core_SelectValues::customGroupExtends(), array_fill_keys($nonGenericEntities, 1));
  if (!array_key_exists($entity, $customFieldEntities)) {
    return;
  }
  $values = [];
  _civicrm_api3_custom_format_params($params, $values, $entity);
  $params = array_merge($params, $values);
}

/**
 * We can't rely on downstream to add separators to checkboxes so we'll check here.
 *
 * We should look at pushing to BAO function
 * and / or validate function but this is a safe place for now as it has massive test coverage & we can keep the change very specific
 * note that this is specifically tested in the GRANT api test case so later refactoring should use that as a checking point
 *
 * We will only alter the value if we are sure that changing it will make it correct - if it appears wrong but does not appear to have a clear fix we
 * don't touch - lots of very cautious code in here
 *
 * The resulting array should look like
 * array(
 *  'key' => 1,
 *  'key1' => 1,
 * );
 *
 * OR one or more keys wrapped in a CRM_Core_DAO::VALUE_SEPARATOR - either it accepted by the receiving function
 *
 * @todo - we are probably skipping handling disabled options as presumably getoptions is not giving us them. This should be non-regressive but might
 * be fixed in future
 *
 * @param mixed $checkboxFieldValue
 * @param string $customFieldLabel
 * @param string $entity
 */
function formatCheckBoxField(&$checkboxFieldValue, $customFieldLabel, $entity) {

  if (is_string($checkboxFieldValue) && stristr($checkboxFieldValue, CRM_Core_DAO::VALUE_SEPARATOR)) {
    // We can assume it's pre-formatted.
    return;
  }
  $options = civicrm_api($entity, 'getoptions', ['field' => $customFieldLabel, 'version' => 3]);
  if (!empty($options['is_error'])) {
    // The check is precautionary - can probably be removed later.
    return;
  }

  if (is_array($checkboxFieldValue) && empty($checkboxFieldValue)) {
    $checkboxFieldValue = '';
    return;
  }
  $options = $options['values'];
  $validValue = TRUE;
  if (is_array($checkboxFieldValue)) {
    foreach ($checkboxFieldValue as $key => $value) {
      if (!array_key_exists($key, $options)) {
        $validValue = FALSE;
      }
    }
    if ($validValue) {
      // we have been passed an array that is already in the 'odd' custom field format
      return;
    }
  }

  // so we either have an array that is not keyed by the value or we have a string that doesn't hold separators
  // if the array only has one item we'll treat it like any other string
  if (is_array($checkboxFieldValue) && count($checkboxFieldValue) == 1) {
    $possibleValue = reset($checkboxFieldValue);
  }
  if (is_string($checkboxFieldValue)) {
    $possibleValue = $checkboxFieldValue;
  }
  if (isset($possibleValue) && array_key_exists($possibleValue, $options)) {
    $checkboxFieldValue = CRM_Core_DAO::VALUE_SEPARATOR . $possibleValue . CRM_Core_DAO::VALUE_SEPARATOR;
    return;
  }
  elseif (is_array($checkboxFieldValue)) {
    // so this time around we are considering the values in the array
    $possibleValues = $checkboxFieldValue;
    $formatValue = TRUE;
  }
  elseif (stristr($checkboxFieldValue, ',')) {
    $formatValue = TRUE;
    //lets see if we should separate it - we do this near the end so we
    // ensure we have already checked that the comma is not part of a legitimate match
    // and of course, we don't make any changes if we don't now have matches
    $possibleValues = explode(',', $checkboxFieldValue);
  }
  else {
    // run out of ideas as to what the format might be - if it's a string it doesn't match with or without the ','
    return;
  }

  foreach ($possibleValues as $index => $possibleValue) {
    if (array_key_exists($possibleValue, $options)) {
      // do nothing - we will leave formatValue set to true unless another value is not found (which would cause us to ignore the whole value set)
    }
    elseif (array_key_exists(trim($possibleValue), $options)) {
      $possibleValues[$index] = trim($possibleValue);
    }
    else {
      $formatValue = FALSE;
    }
  }
  if ($formatValue) {
    $checkboxFieldValue = CRM_Core_DAO::VALUE_SEPARATOR . implode(CRM_Core_DAO::VALUE_SEPARATOR, $possibleValues) . CRM_Core_DAO::VALUE_SEPARATOR;
  }
}

/**
 * Function to do a 'standard' api get - when the api is only doing a $bao->find then use this.
 *
 * @param string|CRM_Core_DAO $bao_name
 *   Name of BAO.
 * @param array $params
 *   Params from api.
 * @param bool $returnAsSuccess
 *   Return in api success format.
 * @param string $entity
 * @param CRM_Utils_SQL_Select|NULL $sql
 *   Extra SQL bits to add to the query. For filtering current events, this might be:
 *   CRM_Utils_SQL_Select::fragment()->where('(start_date >= CURDATE() || end_date >= CURDATE())');
 * @param bool $uniqueFields
 *   Should unique field names be returned (for backward compatibility)
 *
 * @return array
 */
function _civicrm_api3_basic_get($bao_name, $params, $returnAsSuccess = TRUE, $entity = "", $sql = NULL, $uniqueFields = FALSE) {
  $entity = $entity ?: CRM_Core_DAO_AllCoreTables::getEntityNameForClass($bao_name);
  $options = _civicrm_api3_get_options_from_params($params);

  // Skip query if table doesn't exist yet due to pending upgrade
  if (!$bao_name::tableHasBeenAdded()) {
    \Civi::log()->warning("Could not read from {$entity} before table has been added. Upgrade required.", ['civi.tag' => 'upgrade_needed']);
    $result = [];
  }
  else {
    $query = new \Civi\API\Api3SelectQuery($entity, $params['check_permissions'] ?? FALSE);
    $query->where = $params;
    if ($options['is_count']) {
      $query->select = ['count_rows'];
    }
    else {
      $query->select = array_keys(array_filter($options['return']));
      $query->orderBy = $options['sort'];
      $query->isFillUniqueFields = $uniqueFields;
    }
    $query->limit = $options['limit'];
    $query->offset = $options['offset'];
    $query->merge($sql);
    $result = $query->run();
  }

  if ($returnAsSuccess) {
    return civicrm_api3_create_success($result, $params, $entity, 'get');
  }
  return $result;
}

/**
 * Function to do a 'standard' api create - when the api is only doing a $bao::create then use this.
 *
 * @param string $bao_name
 *   Name of BAO Class.
 * @param array $params
 *   Parameters passed into the api call.
 * @param string $entity
 *   Entity - pass in if entity is non-standard & required $ids array.
 *
 * @throws CRM_Core_Exception
 * @throws \Civi\API\Exception\UnauthorizedException
 * @return array
 */
function _civicrm_api3_basic_create($bao_name, &$params, $entity = NULL) {
  _civicrm_api3_check_edit_permissions($bao_name, $params);
  _civicrm_api3_format_params_for_create($params, $entity);

  if (method_exists($bao_name, 'create')) {
    $fct = 'create';
  }
  elseif (method_exists($bao_name, 'add')) {
    $fct = 'add';
  }
  if (!isset($fct) || \Civi\Api4\Utils\ReflectionUtils::isMethodDeprecated($bao_name, $fct)) {
    $fct = 'writeRecord';
  }
  $bao = $bao_name::$fct($params);

  if (is_null($bao)) {
    return civicrm_api3_create_error("Entity not created ($bao_name::$fct)");
  }
  elseif (is_a($bao, 'CRM_Core_Error')) {
    //some weird circular thing means the error takes itself as an argument
    $msg = $bao->getMessages($bao);
    // the api deals with entities on a one-by-one basis. However, the contribution bao pushes entities
    // onto the error object - presumably because the contribution import is not handling multiple errors correctly
    // so we need to reset the error object here to avoid getting concatenated errors
    //@todo - the mulitple error handling should be moved out of the contribution object to the import / multiple entity processes
    CRM_Core_Error::singleton()->reset();
    throw new CRM_Core_Exception($msg);
  }
  else {
    // If we have custom fields the BAO may have taken care of it or we may have to.
    // DAO::writeRecord always handles custom data.
    if (isset($params['custom']) && $fct !== 'writeRecord') {
      // List of BAOs that write custom data in their create or add function.
      $alreadyHandled = array_keys(CRM_Core_BAO_CustomQuery::$extendsMap);
      $alreadyHandled[] = 'ActivityContact';
      $alreadyHandled[] = 'Batch';
      $alreadyHandled[] = 'CustomField';
      $alreadyHandled[] = 'EntityBatch';
      $alreadyHandled[] = 'IM';
      $alreadyHandled[] = 'Mailing';
      $alreadyHandled[] = 'MailingAB';
      $alreadyHandled[] = 'OpenID';
      $alreadyHandled[] = 'Phone';
      $alreadyHandled[] = 'PledgePayment';
      $alreadyHandled[] = 'PriceField';
      $alreadyHandled[] = 'PriceFieldValue';
      $alreadyHandled[] = 'RelationshipType';
      $alreadyHandled[] = 'SavedSearch';
      $alreadyHandled[] = 'Tag';
      $alreadyHandled[] = 'Website';
      if (!in_array($entity, $alreadyHandled)) {
        CRM_Core_BAO_CustomValueTable::store($params['custom'], CRM_Core_DAO_AllCoreTables::getTableForClass(CRM_Core_DAO_AllCoreTables::getDAONameForEntity($entity)), $bao->id);
      }
    }
    $values = [];
    _civicrm_api3_object_to_array($bao, $values[$bao->id]);
    return civicrm_api3_create_success($values, $params, $entity, 'create', $bao);
  }
}

/**
 * For BAO's which don't have a create() or add() functions, use this fallback implementation.
 *
 * @param string|CRM_Core_DAO $bao_name
 * @param array $params
 *
 * @throws CRM_Core_Exception
 *
 * @return CRM_Core_DAO|NULL
 *   An instance of the BAO
 */
function _civicrm_api3_basic_create_fallback($bao_name, $params) {
  return $bao_name::writeRecord($params);
}

/**
 * Function to do a 'standard' api del.
 *
 * When the api is only doing a $bao::del then use this if api::del doesn't exist it will try DAO delete method.
 *
 * @param string|CRM_Core_DAO $bao_name
 * @param array $params
 *
 * @return array
 *   API result array
 *
 * @throws CRM_Core_Exception
 * @throws \Civi\API\Exception\UnauthorizedException
 * @throws \CRM_Core_Exception
 */
function _civicrm_api3_basic_delete($bao_name, &$params) {
  civicrm_api3_verify_mandatory($params, NULL, ['id']);
  _civicrm_api3_check_edit_permissions($bao_name, ['id' => $params['id']]);
  if (method_exists($bao_name, 'del') && !\Civi\Api4\Utils\ReflectionUtils::isMethodDeprecated($bao_name, 'del')) {
    $args = [&$params['id']];
    $dao = new $bao_name();
    $dao->id = $params['id'];
    if ($dao->find()) {
      $bao = call_user_func_array([$bao_name, 'del'], $args);
      if ($bao !== FALSE) {
        return civicrm_api3_create_success();
      }
      throw new CRM_Core_Exception('Could not delete entity id ' . $params['id']);
    }
    throw new CRM_Core_Exception('Could not delete entity id ' . $params['id']);
  }
  else {
    $bao_name::deleteRecord($params);
    return civicrm_api3_create_success();
  }
}

/**
 * Get custom data for the given entity & Add it to the returnArray.
 *
 * This looks like 'custom_123' = 'custom string' AND
 * 'custom_123_1' = 'custom string'
 * Where 123 is field value & 1 is the id within the custom group data table (value ID)
 *
 * @param array $returnArray
 *   Array to append custom data too - generally $result[4] where 4 is the entity id.
 * @param bool $checkPermission
 * @param string $entity
 *   E.g membership, event.
 * @param int $entity_id
 * @param int $groupID
 *   Per CRM_Core_BAO_CustomGroup::getTree.
 * @param int $subType
 *   E.g. membership_type_id where custom data doesn't apply to all membership types.
 * @param string $subName
 *   Subtype of entity.
 *
 * @throws \CRM_Core_Exception
 */
function _civicrm_api3_custom_data_get(&$returnArray, $checkPermission, $entity, $entity_id, $groupID = NULL, $subType = NULL, $subName = NULL) {
  $groupTree = CRM_Core_BAO_CustomGroup::getTree($entity,
    NULL,
    $entity_id,
    $groupID,
    $subType,
    $subName,
    TRUE,
    NULL,
    TRUE,
    $checkPermission ? CRM_Core_Permission::VIEW : FALSE
  );
  $groupTree = CRM_Core_BAO_CustomGroup::formatGroupTree($groupTree, 1);
  $customValues = [];
  CRM_Core_BAO_CustomGroup::setDefaults($groupTree, $customValues);
  $fieldInfo = [];
  foreach ($groupTree as $set) {
    $fieldInfo += ($set['fields'] ?? []);
  }
  if (!empty($customValues)) {
    foreach ($customValues as $key => $val) {
      // per standard - return custom_fieldID
      $id = CRM_Core_BAO_CustomField::getKeyID($key);
      $returnArray['custom_' . $id] = $val;

      //not standard - but some api did this so guess we should keep - cheap as chips
      $returnArray[$key] = $val;

      // Shim to restore legacy behavior of ContactReference custom fields
      if (!empty($fieldInfo[$id]) && $fieldInfo[$id]['data_type'] === 'ContactReference') {
        $returnArray['custom_' . $id . '_id'] = $returnArray[$key . '_id'] = $val;

        if (is_array($val)) {
          $lookupValues = [];
          foreach ($val as $contactId) {
            $lookupValues[] = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $contactId, 'sort_name');
          }
          $returnArray['custom_' . $id] = $returnArray[$key] = $lookupValues;
        }
        else {
          $returnArray['custom_' . $id] = $returnArray[$key] = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $val, 'sort_name');
        }
      }
    }
  }
}

/**
 * Used by the Validate API.
 *
 * @param string $entity
 * @param string $action
 * @param array $params
 *
 * @return array $errors
 * @throws \CRM_Core_Exception
 */
function _civicrm_api3_validate($entity, $action, $params) {
  $errors = [];
  $fields = civicrm_api3($entity, 'getfields', ['sequential' => 1, 'api_action' => $action]);
  $fields = $fields['values'];

  // Check for required fields.
  foreach ($fields as $values) {
    if (!empty($values['api.required']) && empty($params[$values['name']])) {
      $errors[$values['name']] = [
        'message' => 'Mandatory key(s) missing from params array: ' . $values['name'],
        'code' => 'mandatory_missing',
      ];
    }
  }

  // Select only the fields which have been input as a param.
  $finalfields = [];
  foreach ($fields as $values) {
    if (array_key_exists($values['name'], $params)) {
      $finalfields[] = $values;
    }
  }

  // This derives heavily from the function "_civicrm_api3_validate_fields".
  // However, the difference is that try-catch blocks are nested in the loop, making it
  // possible for us to get all errors in one go.
  foreach ($finalfields as $fieldInfo) {
    $fieldName = $fieldInfo['name'];
    try {
      _civicrm_api3_validate_switch_cases($fieldName, $fieldInfo, $entity, $params, $action);
    }
    catch (Exception $e) {
      $errors[$fieldName] = [
        'message' => $e->getMessage(),
        'code' => 'incorrect_value',
      ];
    }
  }

  return [$errors];
}

/**
 * Used by the Validate API.
 * @param string $fieldName
 * @param array $fieldInfo
 * @param string $entity
 * @param array $params
 * @param string $action
 *
 * @throws CRM_Core_Exception
 * @throws Exception
 */
function _civicrm_api3_validate_switch_cases($fieldName, $fieldInfo, $entity, $params, $action) {
  switch ($fieldInfo['type'] ?? NULL) {
    case CRM_Utils_Type::T_INT:
      _civicrm_api3_validate_integer($params, $fieldName, $fieldInfo, $entity);
      break;

    case CRM_Utils_Type::T_DATE:
    case CRM_Utils_Type::T_DATE + CRM_Utils_Type::T_TIME:
    case CRM_Utils_Type::T_TIMESTAMP:
      //field is of type date or datetime
      _civicrm_api3_validate_date($params, $fieldName, $fieldInfo);
      break;

    case CRM_Utils_Type::T_TEXT:
    case CRM_Utils_Type::T_STRING:
      _civicrm_api3_validate_string($params, $fieldName, $fieldInfo, $entity, $action);
      break;

    case CRM_Utils_Type::T_MONEY:
      [$fieldValue, $op] = _civicrm_api3_field_value_check($params, $fieldName);

      foreach ((array) $fieldValue as $fieldvalue) {
        if (!CRM_Utils_Rule::money($fieldvalue) && !empty($fieldvalue)) {
          throw new Exception($fieldName . ' is  not a valid amount: ' . $params[$fieldName]);
        }
      }
      break;
  }
}

/**
 * Validate fields being passed into API.
 *
 * This function relies on the getFields function working accurately
 * for the given API.
 *
 * As of writing only date was implemented.
 *
 * @param string $entity
 * @param string $action
 * @param array $params
 *   -.
 * @param array $fields
 *   Response from getfields all variables are the same as per civicrm_api.
 *
 * @throws Exception
 */
function _civicrm_api3_validate_fields($entity, $action, &$params, $fields) {
  //CRM-15792 handle datetime for custom fields below code handles chain api call
  $chainApikeys = array_flip(preg_grep("/^api./", array_keys($params)));
  if (!empty($chainApikeys) && is_array($chainApikeys)) {
    foreach ($chainApikeys as $key => $value) {
      if (is_array($params[$key])) {
        $chainApiParams = array_intersect_key($fields, $params[$key]);
        $customFields = array_fill_keys(array_keys($params[$key]), $key);
      }
    }
  }
  $fields = array_intersect_key($fields, $params);
  if (!empty($chainApiParams)) {
    $fields = array_merge($fields, $chainApiParams);
  }
  foreach ($fields as $fieldName => $fieldInfo) {
    switch ($fieldInfo['type'] ?? NULL) {
      case CRM_Utils_Type::T_INT:
        //field is of type integer
        _civicrm_api3_validate_integer($params, $fieldName, $fieldInfo, $entity);
        break;

      case CRM_Utils_Type::T_DATE:
      case CRM_Utils_Type::T_DATE + CRM_Utils_Type::T_TIME:
      case CRM_Utils_Type::T_TIMESTAMP:
        //field is of type date or datetime
        if (!empty($customFields) && array_key_exists($fieldName, $customFields)) {
          $dateParams = &$params[$customFields[$fieldName]];
        }
        else {
          $dateParams = &$params;
        }
        _civicrm_api3_validate_date($dateParams, $fieldName, $fieldInfo);
        break;

      case CRM_Utils_Type::T_TEXT:
      case CRM_Utils_Type::T_STRING:
        _civicrm_api3_validate_string($params, $fieldName, $fieldInfo, $entity, $action);
        break;

      case CRM_Utils_Type::T_MONEY:
        [$fieldValue, $op] = _civicrm_api3_field_value_check($params, $fieldName);
        if (str_contains(($op ?? ''), 'NULL') || str_contains(($op ?? ''), 'EMPTY')) {
          break;
        }
        foreach ((array) $fieldValue as $fieldvalue) {
          if (!CRM_Utils_Rule::money($fieldvalue) && !empty($fieldvalue)) {
            throw new Exception($fieldName . " is  not a valid amount: " . $params[$fieldName]);
          }
        }
        break;
    }
  }
}

/**
 * Validate foreign key values of fields being passed into API.
 *
 * This function relies on the getFields function working accurately
 * for the given API.
 *
 * @param string $entity
 * @param string $action
 * @param array $params
 *
 * @param array $fields
 *   Response from getfields all variables are the same as per civicrm_api.
 *
 * @throws Exception
 */
function _civicrm_api3_validate_foreign_keys($entity, $action, &$params, $fields) {
  // intensive checks - usually only called after DB level fail
  foreach ($fields as $fieldName => $fieldInfo) {
    if (!empty($fieldInfo['FKClassName'])) {
      if (!empty($params[$fieldName])) {
        foreach ((array) $params[$fieldName] as $fieldValue) {
          _civicrm_api3_validate_constraint($fieldValue, $fieldName, $fieldInfo, $entity);
        }
      }
      elseif (!empty($fieldInfo['required'])) {
        throw new Exception("DB Constraint Violation - $fieldName should possibly be marked as mandatory for $entity,$action API. If so, please raise a bug report.");
      }
    }
    if (!empty($fieldInfo['api.unique'])) {
      $params['entity'] = $entity;
      _civicrm_api3_validate_unique_key($params, $fieldName);
    }
  }
}

/**
 * Validate date fields being passed into API.
 *
 * It currently converts both unique fields and DB field names to a mysql date.
 * @todo - probably the unique field handling & the if exists handling is now done before this
 * function is reached in the wrapper - can reduce this code down to assume we
 * are only checking the passed in field
 *
 * It also checks against the RULE:date function. This is a centralisation of code that was scattered and
 * may not be the best thing to do. There is no code level documentation on the existing functions to work off
 *
 * @param array $params
 *   Params from civicrm_api.
 * @param string $fieldName
 *   Uniquename of field being checked.
 * @param array $fieldInfo
 *   Array of fields from getfields function.
 *
 * @throws Exception
 */
function _civicrm_api3_validate_date(&$params, &$fieldName, &$fieldInfo) {
  [$fieldValue, $op] = _civicrm_api3_field_value_check($params, $fieldName);
  if (str_contains(($op ?? ''), 'NULL') || str_contains(($op ?? ''), 'EMPTY')) {
    return;
  }

  if ($fieldValue === 'null' && empty($fieldInfo['api.required'])) {
    // This is the wierd & wonderful way PEAR sets null.
    return;
  }

  //should we check first to prevent it from being copied if they have passed in sql friendly format?
  if (!empty($params[$fieldInfo['name']])) {
    $fieldValue = _civicrm_api3_getValidDate($fieldValue, $fieldInfo['name'], $fieldInfo['type']);
  }
  if (($fieldInfo['name'] ?? NULL) !== $fieldName && !empty($fieldValue)) {
    $fieldValue = _civicrm_api3_getValidDate($fieldValue, $fieldName, $fieldInfo['type']);
  }

  if (!empty($op)) {
    $params[$fieldName][$op] = $fieldValue;
  }
  else {
    $params[$fieldName] = $fieldValue;
  }
}

/**
 * Convert date into BAO friendly date.
 *
 * We accept 'whatever strtotime accepts'
 *
 * @param string $dateValue
 * @param string $fieldName
 * @param int $fieldType
 *
 * @throws Exception
 * @return mixed
 */
function _civicrm_api3_getValidDate($dateValue, $fieldName, $fieldType) {
  if (is_array($dateValue)) {
    foreach ($dateValue as $key => $value) {
      $dateValue[$key] = _civicrm_api3_getValidDate($value, $fieldName, $fieldType);
    }
    return $dateValue;
  }
  if (strtotime($dateValue) === FALSE) {
    throw new Exception($fieldName . " is not a valid date: " . $dateValue);
  }
  $format = ($fieldType == CRM_Utils_Type::T_DATE) ? 'Ymd000000' : 'YmdHis';
  return CRM_Utils_Date::processDate($dateValue, NULL, FALSE, $format);
}

/**
 * Validate foreign constraint fields being passed into API.
 *
 * @param mixed $fieldValue
 * @param string $fieldName
 *   Unique name of field being checked.
 * @param array $fieldInfo
 *   Array of fields from getfields function.
 * @param string $entity
 *
 * @throws \CRM_Core_Exception
 */
function _civicrm_api3_validate_constraint($fieldValue, $fieldName, $fieldInfo, $entity) {
  $daoName = $fieldInfo['FKClassName'];
  $fieldInfo = [$fieldName => $fieldInfo];
  $params = [$fieldName => $fieldValue];
  _civicrm_api3_validate_fields($entity, NULL, $params, $fieldInfo);
  /** @var CRM_Core_DAO $dao */
  $dao = new $daoName();
  $dao->id = $params[$fieldName];
  $dao->selectAdd();
  $dao->selectAdd('id');
  if (!$dao->find()) {
    throw new CRM_Core_Exception("$fieldName is not valid : " . $fieldValue);
  }
}

/**
 * Validate foreign constraint fields being passed into API.
 *
 * @param array $params
 *   Params from civicrm_api.
 * @param string $fieldName
 *   Uniquename of field being checked.
 *
 * @throws Exception
 */
function _civicrm_api3_validate_unique_key(&$params, &$fieldName) {
  [$fieldValue, $op] = _civicrm_api3_field_value_check($params, $fieldName);
  if (str_contains(($op ?? ''), 'NULL') || str_contains(($op ?? ''), 'EMPTY')) {
    return;
  }
  $existing = civicrm_api($params['entity'], 'get', [
    'version' => $params['version'],
    $fieldName => $fieldValue,
  ]);
  // an entry already exists for this unique field
  if ($existing['count'] == 1) {
    // question - could this ever be a security issue?
    throw new CRM_Core_Exception("Field: `$fieldName` must be unique. An conflicting entity already exists - id: " . $existing['id']);
  }
}

/**
 * Generic implementation of the "replace" action.
 *
 * Replace the old set of entities (matching some given keys) with a new set of
 * entities (matching the same keys).
 *
 * @note This will verify that 'values' is present, but it does not directly verify
 * any other parameters.
 *
 * @param string $entity
 *   Entity name.
 * @param array $params
 *   Params from civicrm_api, including:.
 *   - 'values': an array of records to save
 *   - all other items: keys which identify new/pre-existing records.
 *
 * @return array|int
 */
function _civicrm_api3_generic_replace($entity, $params) {

  $transaction = new CRM_Core_Transaction();
  try {
    if (!is_array($params['values'])) {
      throw new Exception("Mandatory key(s) missing from params array: values");
    }

    // Extract the keys -- somewhat scary, don't think too hard about it
    $baseParams = _civicrm_api3_generic_replace_base_params($params);

    // Lookup pre-existing records
    $preexisting = civicrm_api($entity, 'get', $baseParams, $params);
    if (civicrm_error($preexisting)) {
      $transaction->rollback();
      return $preexisting;
    }

    // Save the new/updated records
    $creates = [];
    foreach ($params['values'] as $replacement) {
      // Sugar: Don't force clients to duplicate the 'key' data
      $replacement = array_merge($baseParams, $replacement);
      $action      = (isset($replacement['id']) || isset($replacement[$entity . '_id'])) ? 'update' : 'create';
      $create      = civicrm_api($entity, $action, $replacement);
      if (civicrm_error($create)) {
        $transaction->rollback();
        return $create;
      }
      foreach ($create['values'] as $entity_id => $entity_value) {
        $creates[$entity_id] = $entity_value;
      }
    }

    // Remove stale records
    $staleIDs = array_diff(
      array_keys($preexisting['values']),
      array_keys($creates)
    );
    foreach ($staleIDs as $staleID) {
      $delete = civicrm_api($entity, 'delete', [
        'version' => $params['version'],
        'id' => $staleID,
      ]);
      if (civicrm_error($delete)) {
        $transaction->rollback();
        return $delete;
      }
    }

    return civicrm_api3_create_success($creates, $params);
  }
  catch (PEAR_Exception $e) {
    $transaction->rollback();
    return civicrm_api3_create_error($e->getMessage());
  }
  catch (Exception $e) {
    $transaction->rollback();
    return civicrm_api3_create_error($e->getMessage());
  }
}

/**
 * Replace base parameters.
 *
 * @param array $params
 *
 * @return array
 */
function _civicrm_api3_generic_replace_base_params($params) {
  $baseParams = $params;
  unset($baseParams['values']);
  unset($baseParams['sequential']);
  unset($baseParams['options']);
  $baseParams['options']['limit'] = 0;
  return $baseParams;
}

/**
 * Returns fields allowable by api.
 *
 * @param string $entity
 *   String Entity to query.
 * @param bool $unique
 *   Index by unique fields?.
 * @param array $params
 *
 * @return array
 */
function _civicrm_api_get_fields($entity, $unique = FALSE, &$params = []) {
  $unsetIfEmpty = [
    'dataPattern',
    'headerPattern',
    'default',
    'export',
    'import',
  ];
  $dao = _civicrm_api3_get_DAO($entity);
  if (empty($dao)) {
    return [];
  }
  $fields = $dao::fields();
  $supportedFields = $dao::getSupportedFields();

  foreach ($fields as $name => $field) {
    // Denote as core field
    $fields[$name]['is_core_field'] = TRUE;
    // Set html attributes for text fields
    if (isset($field['html'])) {
      $fields[$name]['html'] += (array) $dao::makeAttribute($field);
    }
    // Delete field if not supported by current db schema (prevents errors when there are pending db updates)
    if (!isset($supportedFields[$field['name']])) {
      unset($fields[$name]);
    }
  }

  // replace uniqueNames by the normal names as the key
  if (empty($unique)) {
    foreach ($fields as $name => &$field) {
      //getting rid of unused attributes
      foreach ($unsetIfEmpty as $attr) {
        if (empty($field[$attr])) {
          unset($field[$attr]);
        }
      }
      if ($name == $field['name']) {
        continue;
      }
      if (array_key_exists($field['name'], $fields)) {
        $field['error'] = 'name conflict';
        // it should never happen, but better safe than sorry
        continue;
      }
      $fields[$field['name']] = $field;
      $fields[$field['name']]['uniqueName'] = $name;
      unset($fields[$name]);
    }
  }
  // Translate FKClassName to the corresponding api
  foreach ($fields as $name => &$field) {
    if (!empty($field['FKClassName'])) {
      $FKApi = CRM_Core_DAO_AllCoreTables::getEntityNameForClass($field['FKClassName']);
      if ($FKApi) {
        $field['FKApiName'] = $FKApi;
      }
    }
  }
  $fields += _civicrm_api_get_custom_fields($entity, $params);
  return $fields;
}

/**
 * Return an array of fields for a given entity.
 *
 * This is the same as the BAO function but fields are prefixed with 'custom_' to represent api params.
 *
 * @param string $entity
 * @param array $params
 *
 * @return array
 */
function _civicrm_api_get_custom_fields($entity, &$params) {
  $entity = CRM_Core_DAO_AllCoreTables::convertEntityNameToCamel($entity);
  if ($entity == 'Contact') {
    // Use sub-type if available, otherwise "NULL" to fetch from all contact types
    $entity = $params['contact_type'] ?? NULL;
  }
  $customfields = CRM_Core_BAO_CustomField::getFields($entity,
    FALSE,
    FALSE,
    // we could / should probably test for other subtypes here - e.g. activity_type_id
    $params['contact_sub_type'] ?? NULL,
    NULL,
    FALSE,
    FALSE,
    FALSE
  );

  $ret = [];

  foreach ($customfields as $key => $value) {
    // Regular fields have a 'name' property
    $value['name'] = 'custom_' . $key;
    $value['title'] = $value['label'];
    if ($value['data_type'] == 'Date' && ($value['time_format'] ?? 0) > 0) {
      $value['data_type'] = 'DateTime';
    }
    $value['type'] = CRM_Core_BAO_CustomField::dataToType()[$value['data_type']] ?? NULL;
    $ret['custom_' . $key] = $value;
  }
  return $ret;
}

/**
 * Fill params array with alternate (alias) values where a field has an alias and that is filled & the main field isn't.
 *
 * If multiple aliases the last takes precedence
 *
 * Function also swaps unique fields for non-unique fields & vice versa.
 *
 * @param array $apiRequest
 * @param array $fields
 */
function _civicrm_api3_swap_out_aliases(&$apiRequest, $fields) {
  foreach ($fields as $field => $values) {
    $uniqueName = $values['uniqueName'] ?? NULL;
    if (!empty($values['api.aliases'])) {
      // if aliased field is not set we try to use field alias
      if (!isset($apiRequest['params'][$field])) {
        foreach ($values['api.aliases'] as $alias) {
          if (isset($apiRequest['params'][$alias])) {
            $apiRequest['params'][$field] = $apiRequest['params'][$alias];
          }
          //unset original field  nb - need to be careful with this as it may bring inconsistencies
          // out of the woodwork but will be implementing only as _spec function extended
          unset($apiRequest['params'][$alias]);
        }
      }
    }
    if (!isset($apiRequest['params'][$field]) && !empty($values['name']) && $field != $values['name']
      && isset($apiRequest['params'][$values['name']])
    ) {
      $apiRequest['params'][$field] = $apiRequest['params'][$values['name']];
      // note that it would make sense to unset the original field here but tests need to be in place first
      if ($field != 'domain_version') {
        unset($apiRequest['params'][$values['name']]);
      }
    }
    if (!isset($apiRequest['params'][$field])
      && $uniqueName
      && $field != $uniqueName
      && array_key_exists($uniqueName, $apiRequest['params'])
    ) {
      $apiRequest['params'][$field] = $apiRequest['params'][$values['uniqueName']] ?? NULL;
      // note that it would make sense to unset the original field here but tests need to be in place first
    }
  }

}

/**
 * Validate integer fields being passed into API.
 *
 * It currently converts the incoming value 'user_contact_id' into the id of the currently logged in user.
 *
 * @param array $params
 *   Params from civicrm_api.
 * @param string $fieldName
 *   Uniquename of field being checked.
 * @param array $fieldInfo
 *   Array of fields from getfields function.
 * @param string $entity
 *
 * @throws CRM_Core_Exception
 */
function _civicrm_api3_validate_integer(&$params, $fieldName, &$fieldInfo, $entity) {
  [$fieldValue, $op] = _civicrm_api3_field_value_check($params, $fieldName);
  if ($fieldName === 'auto_renew' && $fieldValue === TRUE) {
    // https://lab.civicrm.org/dev/rc/-/issues/14
    $fieldValue = 1;
  }
  if ($fieldName === 'limit_to' && in_array($fieldValue, [0, '0'], TRUE)) {
    // https://github.com/civicrm/civicrm-core/pull/26881
    // FALSE will bypass the below validation and then the BAO will change it to 2 with a deprecation notice
    $fieldValue = FALSE;
  }
  if (str_contains(($op ?? ''), 'NULL') || str_contains(($op ?? ''), 'EMPTY')) {
    return;
  }

  if (!empty($fieldValue) || $fieldValue === '0' || $fieldValue === 0) {
    // if value = 'user_contact_id' (or similar), replace value with contact id
    if (!is_numeric($fieldValue) && is_scalar($fieldValue)) {
      $realContactId = _civicrm_api3_resolve_contactID($fieldValue);
      if ('unknown-user' === $realContactId) {
        throw new CRM_Core_Exception("\"$fieldName\" \"{$fieldValue}\" cannot be resolved to a contact ID", 2002, ['error_field' => $fieldName, "type" => "integer"]);
      }
      elseif (is_numeric($realContactId)) {
        $fieldValue = $realContactId;
      }
      elseif (is_null($realContactId) && empty($fieldInfo['api.required']) && $fieldValue === 'user_contact_id') {
        // If not mandatory this will be OK. If mandatory it should fail.
        $fieldValue = NULL;
      }
    }
    if (
      !empty($fieldInfo['pseudoconstant']) ||
      !empty($fieldInfo['options']) ||
      // Special case for campaign_id which is no longer a pseudoconstant
      ($fieldName === 'campaign_id' && !CRM_Utils_Rule::positiveInteger($fieldValue))
    ) {
      $additional_lookup_params = [];
      if (strtolower($entity) === 'address' && $fieldName == 'state_province_id') {
        $country_id = _civicrm_api3_resolve_country_id($params);
        if (!empty($country_id)) {
          $additional_lookup_params = ['country_id' => $country_id];
        }
      }
      _civicrm_api3_api_match_pseudoconstant($fieldValue, $entity, $fieldName, $fieldInfo, $op, $additional_lookup_params);
    }

    // After swapping options, ensure we have an integer(s)
    foreach ((array) ($fieldValue) as $value) {
      if ($value && !is_numeric($value) && $value !== 'null' && $value !== NULL && !is_array($value)) {
        throw new CRM_Core_Exception("$fieldName is not a valid integer", 2001, ['error_field' => $fieldName, "type" => "integer"]);
      }
    }

    // Check our field length
    if (is_string($fieldValue) && !empty($fieldInfo['maxlength']) && strlen($fieldValue) > $fieldInfo['maxlength']
      ) {
      throw new CRM_Core_Exception($fieldValue . " is " . strlen($fieldValue) . " characters  - longer than $fieldName length" . $fieldInfo['maxlength'] . ' characters',
        2100, ['field' => $fieldName, "max_length" => $fieldInfo['maxlength']]
      );
    }
  }

  if (!empty($op)) {
    $params[$fieldName][$op] = $fieldValue;
  }
  else {
    $params[$fieldName] = $fieldValue;
  }
}

/**
 * Helper function to determine country_id given the myriad of values for country_id or country that are supported
 * @param array $params
 *
 * @return int|null
 */
function _civicrm_api3_resolve_country_id($params) {
  if (!empty($params['country_id'])) {
    if (is_numeric($params['country_id'])) {
      $country_id = $params['country_id'];
    }
    else {
      $country = new CRM_Core_DAO_Country();
      $country->name = $params['country_id'];
      if (!$country->find(TRUE)) {
        $country->name = NULL;
        $country->iso_code = $params['country_id'];
        $country->find(TRUE);
      }
      if (!empty($country->id)) {
        $country_id = $country->id;
      }
    }
  }
  elseif (!empty($params['country'])) {
    if (is_numeric($params['country'])) {
      $country_id = $params['country'];
    }
    else {
      $country = new CRM_Core_DAO_Country();
      $country->name = $params['country'];
      if (!$country->find(TRUE)) {
        $country->name = NULL;
        $country->iso_code = $params['country'];
        $country->find(TRUE);
      }
      if (!empty($country->id)) {
        $country_id = $country->id;
      }
    }
  }
  return !empty($country_id) ? $country_id : NULL;
}

/**
 * Determine a contact ID using a string expression.
 *
 * @param string $contactIdExpr
 *   E.g. "user_contact_id" or "@user:username".
 *
 * @return int|null|'unknown-user'
 * @throws \CRM_Core_Exception
 */
function _civicrm_api3_resolve_contactID($contactIdExpr) {
  // If value = 'user_contact_id' replace value with logged in user id.
  if ($contactIdExpr == "user_contact_id") {
    return CRM_Core_Session::getLoggedInContactID();
  }
  elseif (preg_match('/^@user:(.*)$/', $contactIdExpr, $matches)) {
    $config = CRM_Core_Config::singleton();

    $ufID = $config->userSystem->getUfId($matches[1]);
    if (!$ufID) {
      return 'unknown-user';
    }

    $contactID = CRM_Core_BAO_UFMatch::getContactId($ufID);
    if (!$contactID) {
      return 'unknown-user';
    }

    return $contactID;
  }
  return NULL;
}

/**
 * Validate html (check for scripting attack).
 *
 * @param array $params
 * @param string $fieldName
 * @param array $fieldInfo
 *
 * @throws CRM_Core_Exception
 */
function _civicrm_api3_validate_html(&$params, &$fieldName, $fieldInfo) {
  [$fieldValue, $op] = _civicrm_api3_field_value_check($params, $fieldName);
  if (strpos(($op ?? ''), 'NULL') || strpos(($op ?? ''), 'EMPTY')) {
    return;
  }
}

/**
 * Validate string fields being passed into API.
 *
 * @param array $params
 *   Params from civicrm_api.
 * @param string $fieldName
 *   Uniquename of field being checked.
 * @param array $fieldInfo
 *   Array of fields from getfields function.
 * @param string $entity
 * @param string $action
 *
 * @throws CRM_Core_Exception
 * @throws Exception
 */
function _civicrm_api3_validate_string(&$params, &$fieldName, &$fieldInfo, $entity, $action) {
  $isGet = substr($action, 0, 3) === 'get';
  [$fieldValue, $op] = _civicrm_api3_field_value_check($params, $fieldName, 'String');
  if (str_contains(($op ?? ''), 'NULL') || str_contains(($op ?? ''), 'EMPTY') || CRM_Utils_System::isNull($fieldValue)) {
    return;
  }

  if (!is_array($fieldValue)) {
    $fieldValue = (string) $fieldValue;
  }

  if ($fieldValue) {
    foreach ((array) $fieldValue as $key => $value) {
      if ($fieldName == 'currency') {
        //When using IN operator $fieldValue is a array of currency codes
        if (!CRM_Utils_Rule::currencyCode($value)) {
          throw new Exception("Currency not a valid code: $value");
        }
      }
    }
  }
  if (!empty($fieldInfo['pseudoconstant']) || !empty($fieldInfo['options'])) {
    try {
      _civicrm_api3_api_match_pseudoconstant($fieldValue, $entity, $fieldName, $fieldInfo, $op);
    }
    catch (CRM_Core_Exception $e) {
      // For get operations, allow any string
      if (!$isGet) {
        throw $e;
      }
    }
  }
  // Check our field length
  elseif (is_string($fieldValue) && !empty($fieldInfo['maxlength']) && mb_strlen($fieldValue ?? '') > $fieldInfo['maxlength']) {
    throw new CRM_Core_Exception("Value for $fieldName is " . mb_strlen($value ?? '') . " characters  - This field has a maxlength of {$fieldInfo['maxlength']} characters.",
      2100, ['field' => $fieldName]
    );
  }

  if (!empty($op)) {
    $params[$fieldName][$op] = $fieldValue;
  }
  else {
    $params[$fieldName] = $fieldValue;
  }
}

/**
 * Validate & swap out any pseudoconstants / options.
 *
 * @param mixed $fieldValue
 * @param string $entity : api entity name
 * @param string $fieldName : field name used in api call (not necessarily the canonical name)
 * @param array $fieldInfo : getfields meta-data
 * @param string $op
 * @param array $additional_lookup_params
 *
 * @throws \CRM_Core_Exception
 */
function _civicrm_api3_api_match_pseudoconstant(&$fieldValue, $entity, $fieldName, $fieldInfo, $op = '=', $additional_lookup_params = []) {
  if (in_array($op, ['>', '<', '>=', '<=', 'LIKE', 'NOT LIKE'])) {
    return;
  }

  $options = $fieldInfo['options'] ?? NULL;

  if (!$options) {
    if (strtolower($entity) == 'profile' && !empty($fieldInfo['entity'])) {
      // We need to get the options from the entity the field relates to.
      $entity = $fieldInfo['entity'];
    }
    $options_lookup_params = [
      'version' => 3,
      'field' => $fieldInfo['name'],
      'context' => 'validate',
    ];
    if (!empty($additional_lookup_params)) {
      $options_lookup_params = array_merge($additional_lookup_params, $options_lookup_params);
    }
    $options = civicrm_api($entity, 'getoptions', $options_lookup_params);

    $options = $options['values'] ?? [];
  }

  if (is_string($fieldValue) && str_contains($fieldValue, CRM_Core_DAO::VALUE_SEPARATOR)) {
    $fieldValue = CRM_Utils_Array::explodePadded($fieldValue);
  }
  // If passed multiple options, validate each.
  if (is_array($fieldValue)) {
    foreach ($fieldValue as &$value) {
      if (!is_array($value)) {
        _civicrm_api3_api_match_pseudoconstant_value($value, $options, $fieldName, $fieldInfo['api.required'] ?? FALSE);
      }
    }
  }
  else {
    _civicrm_api3_api_match_pseudoconstant_value($fieldValue, $options, $fieldName, $fieldInfo['api.required'] ?? FALSE);
  }
}

/**
 * Validate & swap a single option value for a field.
 *
 * @param string $value field value
 * @param array $options array of options for this field
 * @param string $fieldName field name used in api call (not necessarily the canonical name)
 * @param bool $isRequired
 *   Is this a required field or is 'null' an acceptable option. We allow 'null' last
 *   in case we have the weird situation of it being a valid option in which case our
 *   brains will probably explode.
 *
 * @throws CRM_Core_Exception
 */
function _civicrm_api3_api_match_pseudoconstant_value(&$value, $options, $fieldName, $isRequired) {
  // If option is a key, no need to translate
  // or if no options are avaiable for pseudoconstant 'table' property
  if (array_key_exists($value, $options) || !$options) {
    return;
  }

  // Hack for Profile formatting fields
  if ($fieldName === 'field_name' && (str_starts_with($value, 'formatting'))) {
    return;
  }

  // Legacy handling of tag used_for values, see https://github.com/civicrm/civicrm-core/pull/20573
  if ($fieldName === 'used_for') {
    $legacyTagUsedFor = [
      'Activities' => 'Activity',
      'Contacts' => 'Contact',
      'Cases' => 'Case',
      // Attachements [sic] was the original spelling
      'Attachements' => 'File',
    ];
    $value = $legacyTagUsedFor[$value] ?? $value;
  }

  // Translate value into key
  // Cast $value to string to avoid a bug in array_search
  $newValue = array_search((string) $value, $options);
  if ($newValue !== FALSE) {
    $value = $newValue;
    return;
  }
  // Case-insensitive matching
  $newValue = strtolower($value);
  $options = array_map("strtolower", $options);
  $newValue = array_search($newValue, $options);
  if ($newValue === FALSE) {
    if ($value === 'null' && !$isRequired) {
      // CiviMagic syntax for Nulling out the field - let it through.
      return;
    }
    // Legacy support for custom fields: If matching failed by name, fallback to label
    // @see https://lab.civicrm.org/dev/core/-/issues/1816
    if ($customFieldId = CRM_Core_BAO_CustomField::getKeyID($fieldName)) {
      $field = new CRM_Core_BAO_CustomField();
      $field->id = $customFieldId;
      $options = array_map("strtolower", $field->getOptions());
      $newValue = array_search(strtolower($value), $options);
    }
  }
  if ($newValue === FALSE) {
    throw new CRM_Core_Exception("'$value' is not a valid option for field $fieldName", 2001, ['error_field' => $fieldName]);
  }
  $value = $newValue;
}

/**
 * Returns the canonical name of a field.
 *
 * @param string $entity
 *   api entity name (string should already be standardized - no camelCase).
 * @param string $fieldName
 *   any variation of a field's name (name, unique_name, api.alias).
 *
 * @param string $action
 *
 * @return bool|string
 *   FieldName or FALSE if the field does not exist
 */
function _civicrm_api3_api_resolve_alias($entity, $fieldName, $action = 'create') {
  if (!$fieldName) {
    return FALSE;
  }
  if (str_starts_with($fieldName, 'custom_') && is_numeric($fieldName[7])) {
    return $fieldName;
  }
  if ($fieldName === (CRM_Core_DAO_AllCoreTables::convertEntityNameToLower($entity) . '_id')) {
    return 'id';
  }
  $result = civicrm_api($entity, 'getfields', [
    'version' => 3,
    'action' => $action,
  ]);
  $meta = $result['values'];
  if (!isset($meta[$fieldName]['name']) && isset($meta[$fieldName . '_id'])) {
    $fieldName = $fieldName . '_id';
  }
  if (isset($meta[$fieldName])) {
    return $meta[$fieldName]['name'];
  }
  foreach ($meta as $info) {
    if ($fieldName == $info['name'] || $fieldName == ($info['uniqueName'] ?? NULL)) {
      return $info['name'];
    }
    if (array_search($fieldName, $info['api.aliases'] ?? []) !== FALSE) {
      return $info['name'];
    }
  }
  // Create didn't work, try with get
  if ($action == 'create') {
    return _civicrm_api3_api_resolve_alias($entity, $fieldName, 'get');
  }
  return FALSE;
}

/**
 * Check if the function is deprecated.
 *
 * @param string $entity
 * @param array $result
 *
 * @return string|array|null
 */
function _civicrm_api3_deprecation_check($entity, $result = []) {
  if ($entity) {
    $lowercase_entity = _civicrm_api_get_entity_name_from_camel($entity);
    $fnName = "_civicrm_api3_{$lowercase_entity}_deprecation";
    if (function_exists($fnName)) {
      return $fnName($result);
    }
  }
}

/**
 * Get the actual field value.
 *
 * In some case $params[$fieldName] holds Array value in this format Array([operator] => [value])
 * So this function returns the actual field value.
 *
 * @param array $params
 * @param string $fieldName
 * @param string $type
 *
 * @return mixed
 */
function _civicrm_api3_field_value_check(&$params, $fieldName, $type = NULL) {
  $fieldValue = $params[$fieldName] ?? NULL;
  $op = NULL;

  if (!empty($fieldValue) && is_array($fieldValue) &&
    (array_search(key($fieldValue), CRM_Core_DAO::acceptedSQLOperators()) ||
      $type == 'String' && str_contains(key($fieldValue), 'EMPTY'))
  ) {
    $op = key($fieldValue);
    $fieldValue = $fieldValue[$op] ?? NULL;
  }
  return [$fieldValue, $op];
}

/**
 * A generic "get" API based on simple array data. This is comparable to
 * _civicrm_api3_basic_get but does not use DAO/BAO. This is useful for
 * small/mid-size data loaded from external JSON or XML documents.
 *
 * @param string $entity
 * @param array $params
 *   API parameters.
 * @param array $records
 *   List of all records.
 * @param string $idCol
 *   The property which defines the ID of a record
 * @param array $filterableFields
 *   List of filterable fields.
 *
 * @return array
 * @throws \CRM_Core_Exception
 */
function _civicrm_api3_basic_array_get($entity, $params, $records, $idCol, $filterableFields) {
  $options = _civicrm_api3_get_options_from_params($params, TRUE, $entity, 'get');
  $offset = $options['offset'] ?? NULL;
  $limit = $options['limit'] ?? NULL;

  $sort = !empty($options['sort']) ? explode(', ', $options['sort']) : NULL;
  if ($sort) {
    usort($records, function($a, $b) use ($sort) {
      foreach ($sort as $field) {
        [$field, $dir] = array_pad(explode(' ', $field), 2, 'asc');
        $modifier = strtolower($dir) == 'asc' ? 1 : -1;
        if (isset($a[$field]) && isset($b[$field])) {
          if ($a[$field] == $b[$field]) {
            continue;
          }
          return (strnatcasecmp($a[$field], $b[$field]) * $modifier);
        }
        elseif (isset($a[$field]) || isset($b[$field])) {
          return ((isset($a[$field]) ? 1 : -1) * $modifier);
        }
      }
      return 0;
    });
  }

  $matches = [];

  $isMatch = function($recordVal, $searchVal) {
    $operator = '=';
    if (is_array($searchVal) && count($searchVal) === 1 && in_array(array_keys($searchVal)[0], CRM_Core_DAO::acceptedSQLOperators())) {
      $operator = array_keys($searchVal)[0];
      $searchVal = array_values($searchVal)[0];
    }
    switch ($operator) {
      case '=':
      case '!=':
      case '<>':
        return ($recordVal == $searchVal) == ($operator == '=');

      case 'IS NULL':
      case 'IS NOT NULL':
        return is_null($recordVal) == ($operator == 'IS NULL');

      case '>':
        return $recordVal > $searchVal;

      case '>=':
        return $recordVal >= $searchVal;

      case '<':
        return $recordVal < $searchVal;

      case '<=':
        return $recordVal <= $searchVal;

      case 'BETWEEN':
      case 'NOT BETWEEN':
        $between = ($recordVal >= $searchVal[0] && $recordVal <= $searchVal[1]);
        return $between == ($operator == 'BETWEEN');

      case 'LIKE':
      case 'NOT LIKE':
        $pattern = '/^' . str_replace('%', '.*', preg_quote($searchVal, '/')) . '$/i';
        return !preg_match($pattern, $recordVal) == ($operator != 'LIKE');

      case 'IN':
      case 'NOT IN':
        return in_array($recordVal, $searchVal) == ($operator == 'IN');

      default:
        throw new CRM_Core_Exception("Unsupported operator: '$operator' cannot be used with array data");
    }
  };

  $currentOffset = 0;
  foreach ($records as $record) {
    if ($idCol != 'id') {
      $record['id'] = $record[$idCol];
    }
    $match = TRUE;
    foreach ($params as $k => $v) {
      if ($k == 'id') {
        $k = $idCol;
      }
      if (in_array($k, $filterableFields) && !$isMatch($record[$k] ?? NULL, $v)) {
        $match = FALSE;
        break;
      }
    }
    if ($match) {
      if ($currentOffset >= $offset) {
        $matches[$record[$idCol]] = $record;
      }
      if ($limit && count($matches) >= $limit) {
        break;
      }
      $currentOffset++;
    }
  }

  $return = $options['return'] ?? [];
  if (!empty($return)) {
    $return['id'] = 1;
    $matches = CRM_Utils_Array::filterColumns($matches, array_keys($return));
  }

  return civicrm_api3_create_success($matches, $params);
}

/**
 * @param string $bao_name
 * @param array $params
 * @throws \Civi\API\Exception\UnauthorizedException
 */
function _civicrm_api3_check_edit_permissions($bao_name, $params) {
  // For lack of something more clever, here's a whitelist of entities whos permissions
  // are inherited from a contact record.
  // Note, when adding here, also remember to modify _civicrm_api3_permissions()
  $contactEntities = [
    'CRM_Core_BAO_Email',
    'CRM_Core_BAO_Phone',
    'CRM_Core_BAO_Address',
    'CRM_Core_BAO_IM',
    'CRM_Core_BAO_Website',
    'CRM_Core_BAO_OpenID',
  ];
  if (!empty($params['check_permissions']) && in_array($bao_name, $contactEntities)) {
    $cid = !empty($params['contact_id']) ? $params['contact_id'] : CRM_Core_DAO::getFieldValue($bao_name, $params['id'], 'contact_id');
    if (!CRM_Contact_BAO_Contact_Permission::allow($cid, CRM_Core_Permission::EDIT)) {
      throw new \Civi\API\Exception\UnauthorizedException('Permission denied to modify contact record');
    }
  }
}

/**
 * Check if an entity has been modified since the last known modified_date
 *
 * @param string $modifiedDate Last knowm modified_date
 * @param int $id Id of record to check
 * @param string $entity API Entity
 *
 * @return bool
 * @throws \CRM_Core_Exception
 */
function _civicrm_api3_compare_timestamps($modifiedDate, $id, $entity) {
  $currentDbInfo = civicrm_api3($entity, 'getsingle', ['id' => $id]);
  if (strtotime($currentDbInfo['modified_date']) <= strtotime($modifiedDate)) {
    return TRUE;
  }
  return FALSE;
}
