<?php

namespace Civi\Api4\Action\SearchDisplay;

use Civi\API\Exception\UnauthorizedException;
use Civi\API\Request;
use Civi\Api4\Query\SqlField;
use Civi\Api4\SearchDisplay;
use Civi\Api4\Utils\CoreUtil;
use Civi\Api4\Utils\FormattingUtil;

/**
 * Base class for running a search.
 *
 * @method $this setDisplay(array|string $display)
 * @method array|string|null getDisplay()
 * @method $this setSort(array $sort)
 * @method array getSort()
 * @method $this setFilters(array $filters)
 * @method array getFilters()
 * @method $this setSeed(string $seed)
 * @method string getSeed()
 * @method $this setAfform(string $afform)
 * @method string getAfform()
 * @package Civi\Api4\Action\SearchDisplay
 */
abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {

  use \Civi\Api4\Generic\Traits\SavedSearchInspectorTrait;
  use \Civi\Api4\Generic\Traits\ArrayQueryActionTrait;

  /**
   * Either the name of the display or an array containing the display definition (for preview mode)
   *
   * Leave NULL to use the autogenerated default.
   *
   * @var string|array|null
   */
  protected $display;

  /**
   * Array of fields to use for ordering the results
   * @var array
   */
  protected $sort = [];

  /**
   * Search conditions that will be automatically added to the WHERE or HAVING clauses
   * @var array
   */
  protected $filters = [];

  /**
   * Filters passed directly into this display via Afform markup
   * will have their labels appended to the Afform title.
   *
   * @var array
   */
  protected $filterLabels = [];

  /**
   * Integer used as a seed when ordering by RAND().
   * This keeps the order stable enough to use a pager with random sorting.
   *
   * @var int
   */
  protected $seed;

  /**
   * Name of Afform, if this display is embedded (used for permissioning)
   * @var string
   */
  protected $afform;

  /**
   * @var array
   */
  private $_afform;

  /**
   * @var array
   */
  private $tasks;

  /**
   * @var array
   */
  private $entityActions;

  private $editableInfo = [];

  private $currencyFields = [];

  /**
   * @var array
   *   Ex: ['civicrm/foo/bar?id=[id]&widget=gizmo' => 'CRMFooBar1234abcd1234abcd']
   */
  private $_qfKeys = [];

  /**
   * Override execute method to change the result object type
   * @return \Civi\Api4\Result\SearchDisplayRunResult
   */
  public function execute() {
    return parent::execute();
  }

  /**
   * @param \Civi\Api4\Generic\Result $result
   * @throws UnauthorizedException
   * @throws \CRM_Core_Exception
   */
  public function _run(\Civi\Api4\Generic\Result $result) {
    $this->checkPermissionToLoadSearch();
    $this->loadSavedSearch();
    $this->loadSearchDisplay();

    // Displays with acl_bypass must be embedded on an afform which the user has access to
    if (
      $this->checkPermissions && !empty($this->display['acl_bypass']) &&
      !\CRM_Core_Permission::check('all CiviCRM permissions and ACLs') && !$this->loadAfform()
    ) {
      throw new UnauthorizedException('Access denied');
    }

    $this->_apiParams['checkPermissions'] = $this->savedSearch['api_params']['checkPermissions'] = empty($this->display['acl_bypass']);
    $this->display['settings']['columns'] ??= [];

    $this->processResult($result);
  }

  abstract protected function processResult(\Civi\Api4\Result\SearchDisplayRunResult $result);

  /**
   * Transforms each row into an array of raw data and an array of formatted columns
   *
   * @param iterable $result
   * @return array{data: array, columns: array, key: int, cssClass: string}[]
   */
  protected function formatResult(iterable $result): array {
    $rows = [];
    $keyName = $this->getRowKeyName();
    if ($this->savedSearch['api_entity'] === 'RelationshipCache') {
      $keyName = 'relationship_id';
    }
    foreach ($result as $index => $record) {
      $data = $columns = [];
      foreach ($this->getSelectClause() as $key => $item) {
        $data[$key] = $this->getValue($key, $record, $index);
      }
      foreach ($this->display['settings']['columns'] as $column) {
        $columns[] = $this->formatColumn($column, $data, $this->display['settings']);
      }
      $row = [
        'data' => $data,
        'columns' => $columns,
      ];
      $style = $this->getCssStyles($this->display['settings']['cssRules'] ?? [], $data);
      if ($style) {
        $row['cssClass'] = implode(' ', $style);
      }
      if (isset($record[$keyName])) {
        $row['key'] = $record[$keyName];
      }
      $rows[] = $row;
    }
    return $rows;
  }

  /**
   * @param string $key
   * @param array $data
   * @param int $rowIndex
   * @return mixed
   */
  private function getValue($key, $data, $rowIndex) {
    // Get value from api result unless this is a pseudo-field which gets a calculated value
    switch ($key) {
      case 'result_row_num':
        return $rowIndex + 1 + ($this->_apiParams['offset'] ?? 0);

      case 'user_contact_id':
        return \CRM_Core_Session::getLoggedInContactID();

      default:
        if (!empty($data[$key])) {
          $item = $this->getSelectExpression($key);
          if ($item['expr'] instanceof SqlField && isset($item['fields'][$key]) && $item['fields'][$key]['fk_entity'] === 'File') {
            return (string) \CRM_Core_BAO_File::getFileUrl($data[$key]);
          }
        }
        return $data[$key] ?? NULL;
    }
  }

  /**
   * @param array $column
   * @param array $data
   * @param array $settings
   * @return array{dataType: ?string, val: mixed, links: array, edit: array, label: string, title: string, image: array, cssClass: string}
   */
  private function formatColumn(array $column, array $data, array $settings) {
    $column += ['rewrite' => NULL, 'label' => NULL, 'key' => '', 'type' => NULL];
    $out = ['dataType' => NULL];
    switch ($column['type']) {
      case 'field':
      case 'html':
        // Fix keys for pseudo-fields like "CURDATE()"
        $key = str_replace('()', ':', $column['key']);
        $rawValue = $data[$key] ?? NULL;
        if (!$this->hasValue($rawValue) && isset($column['empty_value'])) {
          $out['dataType'] = 'String';
          $out['val'] = $this->replaceTokens($column['empty_value'], $data, 'view');
        }
        elseif ($column['rewrite']) {
          $out['dataType'] = 'String';
          $out['val'] = $this->rewrite($column['rewrite'], $data);
        }
        else {
          $out['dataType'] = $dataType = $this->getSelectExpression($key)['dataType'] ?? NULL;
          $out['val'] = $this->formatViewValue($key, $rawValue, $data, $dataType, $column['format'] ?? NULL);
        }

        if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) || $this->hasValue($out['val']))) {
          $out['label'] = $this->replaceTokens($column['label'], $data, 'view');
        }
        if (!empty($column['link'])) {
          $links = $this->formatFieldLinks($column, $data, $out['val']);
          if ($links) {
            $out['links'] = $links;
          }
        }
        elseif (!empty($column['editable']) && !$column['rewrite'] && empty($settings['editableRow']['disable'])) {
          $edit = $this->formatEditableColumn($column, $data);
          if ($edit) {
            // When internally processing an inline-edit, get all metadata
            if (isset($this->rowKey) && isset($this->values) && array_key_exists($column['key'], $this->values)) {
              $out['edit'] = $edit;
            }
            // Otherwise, the client only needs a boolean
            else {
              $out['edit'] = TRUE;
            }
          }
        }
        if ($column['type'] === 'html') {
          if (is_array($out['val'])) {
            $out['val'] = implode(', ', $out['val']);
          }
          $out['val'] = \CRM_Utils_String::purifyHTML($out['val']);
        }
        break;

      case 'image':
        $out['img'] = $this->formatImage($column, $data);
        if ($out['img']) {
          $out['val'] = $this->replaceTokens($column['image']['alt'] ?? NULL, $data, 'view');
        }
        if ($this->hasValue($column['label']) && (!empty($column['forceLabel']) || $out['img'])) {
          $out['label'] = $this->replaceTokens($column['label'], $data, 'view');
        }
        if (!empty($column['link'])) {
          $links = $this->formatFieldLinks($column, $data, '');
          if ($links) {
            $out['links'] = $links;
          }
        }
        break;

      case 'links':
      case 'buttons':
      case 'menu':
        $out = $this->formatLinksColumn($column, $data);
        break;
    }
    // Format tooltip
    if (isset($column['title']) && strlen($column['title'])) {
      $out['title'] = $this->replaceTokens($column['title'], $data, 'view');
    }
    $cssClass = [];
    // Style rules get applied to entire column if not a link
    if (empty($column['link']) && !empty($column['cssRules'])) {
      $cssClass = $this->getCssStyles($column['cssRules'], $data);
    }
    if (!empty($column['alignment'])) {
      $cssClass[] = $column['alignment'];
    }
    if (!empty($column['nowrap'])) {
      $cssClass[] = 'nowrap';
    }
    if (!empty($column['show_linebreaks'])) {
      if ($column['type'] === 'html') {
        $out['val'] = nl2br($out['val']);
      }
      else {
        $cssClass[] = 'crm-search-field-show-linebreaks';
      }
    }
    if (!empty($out['edit'])) {
      $cssClass[] = 'crm-search-field-editable';
    }
    if ($cssClass) {
      $out['cssClass'] = implode(' ', $cssClass);
    }
    if (!empty($column['icons'])) {
      $out['icons'] = $this->getColumnIcons($column, $data, $out);
    }
    return $out;
  }

  /**
   * Rewrite field value, subtituting tokens and evaluating smarty tags
   *
   * @param string $rewrite
   * @param array $data
   * @param string $format view|raw|url
   * @return string
   */
  protected function rewrite(string $rewrite, array $data, string $format = 'view'): string {
    // Cheap str_contains to skip Smarty processing if not needed
    $hasSmarty = str_contains($rewrite, '{');
    $output = $this->replaceTokens($rewrite, $data, $format);
    if ($hasSmarty) {
      $vars = [];
      $nestedIds = [];
      // Convert dots to nested arrays which are more Smarty-friendly
      foreach ($data as $key => $value) {
        $parent = &$vars;
        $allKeys = $keys = array_map(fn($s) => \CRM_Utils_String::munge($s, '_', 0), explode('.', $key));
        while (count($keys) > 1) {
          $level = array_shift($keys);
          $parent[$level] = $parent[$level] ?? [];
          // Fix collisions between e.g. contact_id & contact_id.display_name by nesting the id
          if (is_scalar($parent[$level])) {
            $nestedIds[] = implode('.', array_slice($allKeys, 0, count($keys)));
            $parent[$level] = [
              'id' => $parent[$level],
            ];
          }
          $parent = &$parent[$level];
        }
        $level = array_shift($keys);
        // Fix collisions between e.g. contact_id & contact_id.display_name by nesting the id
        if (isset($parent[$level]) && is_array($parent[$level])) {
          $nestedIds[] = implode('.', $allKeys);
          $parent[$level]['id'] = $value;
        }
        else {
          $parent[$level] = $value;
        }
      }
      // Fix references to e.g. contact_id as scalar if it was moved by above fixes, change reference to nested id
      foreach (array_unique($nestedIds) as $nestedId) {
        $quotedId = preg_quote('$' . $nestedId);
        $output = preg_replace("/$quotedId(?![.\w])/", '$' . "$nestedId.id", $output);
      }

      $output = \CRM_Utils_String::parseOneOffStringThroughSmarty($output, $vars);
    }
    return $output;
  }

  /**
   * Evaluates conditional style rules
   *
   * Rules are in the format ['css class', 'field_name', 'OPERATOR', 'value']
   *
   * @param array[] $styleRules
   * @param array $data
   * @param int|null $index
   * @return array
   */
  protected function getCssStyles(array $styleRules, array $data, ?int $index = NULL) {
    $classes = [];
    foreach ($styleRules as $clause) {
      $cssClass = $clause[0] ?? '';
      if ($cssClass) {
        $condition = $this->getRuleCondition(array_slice($clause, 1));
        if (is_null($condition[0]) || (self::filterCompare($data, $condition, $index))) {
          $classes[] = $cssClass;
        }
      }
    }
    return $classes;
  }

  /**
   * Add icons to a column
   *
   * Note: Only one icon is allowed per side (left/right).
   * If more than one per side is given, latter icons are treated as fallbacks
   * and only shown if prior ones are missing.
   *
   * @param array $column
   * @param array $data
   * @param array $out
   * @return array
   */
  protected function getColumnIcons(array $column, array $data, array $out): array {
    // Column is either outputting an array of links, or a plain value
    // Links are always an array. Value could be, if field is multivalued or aggregated.
    $value = $out['links'] ?? $out['val'] ?? NULL;
    // Get 0-indexed keys of the values (pad so we have at least one)
    $keys = array_pad(array_keys(array_values((array) $value)), 1, 0);
    $result = [];
    foreach (['left', 'right'] as $side) {
      foreach ($keys as $index) {
        $result[$side][$index] = $this->getColumnIcon($column['icons'], $side, $index, $data, is_array($value));
      }
      // Drop if empty
      if (!array_filter($result[$side])) {
        unset($result[$side]);
      }
    }
    return $result;
  }

  private function getColumnIcon(array $icons, string $side, int $index, array $data, bool $isMulti): ?string {
    // Latter icons are fallbacks, earlier ones take priority
    foreach ($icons as $icon) {
      $iconClass = NULL;
      $icon += ['side' => 'left', 'icon' => NULL];
      if ($icon['side'] !== $side) {
        continue;
      }
      $iconField = !empty($icon['field']) ? $this->renameIfAggregate($icon['field']) : NULL;
      if (!empty($iconField) && !empty($data[$iconField])) {
        // Icon field may be multivalued e.g. contact_sub_type, or it may be aggregated
        // If both base field and icon field are multivalued, use corresponding index
        if ($isMulti && is_array($data[$iconField])) {
          $iconClass = $data[$iconField][$index] ?? NULL;
        }
        // Otherwise get a single value
        else {
          $iconClass = \CRM_Utils_Array::first(array_filter((array) $data[$iconField]));
        }
      }
      $iconClass ??= $icon['icon'];
      if ($iconClass && !empty($icon['if'])) {
        $condition = $this->getRuleCondition($icon['if'], $isMulti);
        if (!is_null($condition[0]) && !(self::filterCompare($data, $condition, $isMulti ? $index : NULL))) {
          continue;
        }
      }
      if ($iconClass) {
        return $iconClass;
      }
    }
    return NULL;
  }

  /**
   * Returns the condition of a cssRules
   *
   * @param array $clause
   * @param bool $isMulti
   * @return array
   */
  protected function getRuleCondition($clause, bool $isMulti = FALSE): array {
    $fieldKey = $clause[0] ?? NULL;
    // For fields used in group by, add aggregation and change comparison operator to CONTAINS
    if ($fieldKey && $this->canAggregate($fieldKey)) {
      if (!$isMulti && !empty($clause[1]) && !in_array($clause[1], ['IS EMPTY', 'IS NOT EMPTY'], TRUE)) {
        $clause[1] = 'CONTAINS';
      }
      $fieldKey = $this->renameIfAggregate($fieldKey);
    }
    // Handle relative dates
    if (!empty($clause[2])) {
      $field = $this->getField($fieldKey);
      if ($field && $field['data_type'] === 'Date') {
        $clause[2] = date('Y-m-d', strtotime($clause[2]));
      }
      if ($field && $field['data_type'] === 'Timestamp') {
        $clause[2] = date('Y-m-d H:i:s', strtotime($clause[2]));
      }
    }
    return [$fieldKey, $clause[1] ?? 'IS NOT EMPTY', $clause[2] ?? NULL];
  }

  /**
   * Return fields needed for the select clause by a set of css rules
   *
   * @param array $cssRules
   * @return array
   */
  protected function getCssRulesSelect($cssRules) {
    $select = [];
    foreach ($cssRules as $clause) {
      $fieldKey = $clause[1] ?? NULL;
      if ($fieldKey) {
        // For fields used in group by, add aggregation
        $select[] = $this->renameIfAggregate($fieldKey, TRUE);
      }
    }
    return $select;
  }

  /**
   * Return fields needed for calculating a column's icons
   *
   * @param array $icons
   * @return array
   */
  protected function getIconsSelect($icons) {
    $select = [];
    foreach ($icons as $icon) {
      if (!empty($icon['field'])) {
        $select[] = $this->renameIfAggregate($icon['field'], TRUE);
      }
      $fieldKey = $icon['if'][0] ?? NULL;
      if ($fieldKey) {
        // For fields used in group by, add aggregation
        $select[] = $this->renameIfAggregate($fieldKey, TRUE);
      }
    }
    return $select;
  }

  /**
   * Format a field value as links
   * @param $column
   * @param $data
   * @param $value
   * @return array{text: string, url: string, target: string}[]
   */
  private function formatFieldLinks($column, $data, $value): array {
    $links = [];
    foreach ((array) $value as $index => $val) {
      $link = $this->formatLink($column['link'], $data, FALSE, $val, $index);
      if ($link) {
        // Style rules get appled to each link
        if (!empty($column['cssRules'])) {
          $link += ['style' => ''];
          $css = $this->getCssStyles($column['cssRules'], $data, $index);
          $link['style'] = trim($link['style'] . ' ' . implode(' ', $css));
        }
        $links[] = $link;
      }
    }
    return $links;
  }

  /**
   * Format links for a menu/buttons/links column
   * @param array $column
   * @param array $data
   * @return array{text: string, url: string, target: string, style: string, icon: string}[]
   */
  private function formatLinksColumn($column, $data): array {
    $out = ['links' => []];
    if (isset($column['text'])) {
      $out['text'] = $this->replaceTokens($column['text'], $data, 'view');
    }
    foreach ($column['links'] as $item) {
      if (!$this->checkLinkConditions($item, $data)) {
        continue;
      }
      $link = $this->formatLink($item, $data);
      if ($link) {
        $out['links'][] = $link;
      }
    }
    return $out;
  }

  /**
   * Format a link to resolve tokens and form the url.
   *
   * There are 3 ways a link can be declared:
   *  1. entity+action
   *  2. entity+task
   *  3. path
   *
   * @param array $link
   * @param array $data
   * @param bool $allowMultiple
   * @param string|NULL $text
   * @param int $index
   * @return array|null
   * @throws \CRM_Core_Exception
   */
  protected function formatLink(array $link, array $data, bool $allowMultiple = FALSE, ?string $text = NULL, $index = 0): ?array {
    $useApi = (!empty($link['entity']) && !empty($link['action']));
    $originalData = $data;
    if (isset($index)) {
      foreach ($data as $key => $value) {
        if (is_array($value)) {
          $data[$key] = $value[$index] ?? NULL;
        }
      }
    }
    if ($useApi) {
      $checkPermissions = !in_array($link['action'], ['view', 'preview'], TRUE);
      if (!empty($link['prefix'])) {
        $data = \CRM_Utils_Array::filterByPrefix($data, $link['prefix']);
      }
      // Hack to support links to relationships
      $linkEntity = ($link['entity'] === 'Relationship') ? 'RelationshipCache' : $link['entity'];
      $apiInfo = (array) civicrm_api4($linkEntity, 'getLinks', [
        'checkPermissions' => $checkPermissions,
        'values' => $data,
        'expandMultiple' => $allowMultiple,
        'where' => [['ui_action', '=', $link['action']]],
      ]);
      if ($allowMultiple && count($apiInfo) > 1) {
        return $this->formatMultiLink($link, $apiInfo, $data);
      }
      $link['path'] = $apiInfo[0]['path'] ?? '';
    }
    elseif (!$this->checkLinkAccess($link, $data)) {
      return NULL;
    }
    // FIXME: We should use $originalData so button links can render tokens correctly. But
    // this doesn't match the getLinks() behavior so is out of scope for now.
    $link['text'] = $text ?? $this->replaceTokens($link['text'], $data, 'view');
    if (!empty($link['task'])) {
      $keys = ['task', 'text', 'title', 'icon', 'style'];
    }
    else {
      $query = [];
      if (($link['csrf'] ?? NULL) === 'qfKey') {
        $query['qfKey'] = $this->getQfKey($link['path']);
      }
      // We use original data so that tokens which rely on array-based columns are correctly rendered.
      $path = $this->replaceTokens($link['path'], $originalData, 'url');
      if (!$path) {
        // Return null if `$link[path]` is empty or if any tokens do not resolve
        return NULL;
      }
      $link['url'] = $this->getUrl($path, $query);
      $keys = ['url', 'text', 'title', 'target', 'icon', 'style', 'autoOpen'];
    }
    $link = array_intersect_key($link, array_flip($keys));
    return array_filter($link, function($value) {
      // "0" is a valid title
      return $value || (is_string($value) && strlen($value));
    });
  }

  /**
   * @param string $pathExpr
   *   Path formula. Should specify an explicit path.
   *   Ex: 'civicrm/foo/bar?id=[id]&widget=gizmo`
   * @return string|null
   */
  private function getQfKey(string $pathExpr): ?string {
    if (isset($this->_qfKeys[$pathExpr])) {
      // No point re-computing this for 100x links per page-view - same value works.
      return $this->_qfKeys[$pathExpr];
    }

    $result = NULL;
    if ($routeName = parse_url($pathExpr, PHP_URL_PATH)) {
      if ($routeItem = \CRM_Core_Menu::get($routeName)) {
        if (!empty($routeItem['page_callback'])) {
          $result = \CRM_Core_Key::get($routeItem['page_callback']);
        }
      }
    }

    $this->_qfKeys[$pathExpr] = $result;
    return $result;
  }

  /**
   * Returns an array of multiple links for use as a dropdown-select when API::getLinks returns > 1 record
   */
  private function formatMultiLink(array $link, array $apiInfo, array $data): array {
    $dropdown = [
      'text' => $this->replaceTokens($link['text'], $data, 'view') ?: $apiInfo[0]['text'],
      'icon' => $link['icon'] ?: $link[0]['icon'],
      'title' => $link['title'],
      'style' => $link['style'],
      'children' => [],
    ];
    foreach ($apiInfo as $child) {
      $dropdown['children'][] = [
        'text' => $child['text'],
        'target' => $child['target'],
        'icon' => $child['icon'],
        'url' => $this->getUrl($child['path']),
      ];
    }
    return $dropdown;
  }

  /**
   * Check if a link should be visible to the user based on their permissions
   *
   * Checks ACLs for all links other than VIEW (presumably if a record is shown in
   * SearchKit then the user already has view access, and the check is expensive).
   *
   * @param array $link
   * @param array $data
   * @return bool
   * @throws \CRM_Core_Exception
   * @throws \Civi\API\Exception\NotImplementedException
   */
  private function checkLinkAccess(array $link, array $data): bool {
    if (empty($link['path']) && empty($link['task'])) {
      return FALSE;
    }
    if (!empty($link['entity']) && !empty($link['action']) && !in_array($link['action'], ['view', 'preview', 'get'], TRUE) && $this->getCheckPermissions()) {
      $actionName = $this->getPermittedLinkAction($link['entity'], $link['action']);
      if (!$actionName) {
        return FALSE;
      }
      if ($actionName === 'create') {
        // No record to check for this action and getPermittedLinkAction says it's allowed; we're good.
        return TRUE;
      }
      $idField = CoreUtil::getIdFieldName($link['entity']);
      $idKey = $this->getIdKeyName($link['entity']);
      $id = $data[$link['prefix'] . $idKey] ?? NULL;
      if ($id) {
        $values = [$idField => $id];
        // If not aggregated, add other values to help checkAccess be efficient
        if (!is_array($data[$link['prefix'] . $idKey])) {
          $values += \CRM_Utils_Array::filterByPrefix($data, $link['prefix']);
        }
        // These 2 lines are the heart of the `checkAccess` api action.
        // Calling this directly is more performant than going through the api wrapper
        $apiRequest = Request::create($link['entity'], $actionName, ['version' => 4]);
        return CoreUtil::checkAccessRecord($apiRequest, $values);
      }
      // No id so cannot possibly update or delete record
      return FALSE;
    }
    return TRUE;
  }

  /**
   * Given entity/action name, return the api action name if the user is allowed to run it.
   *
   * This function serves 2 purposes:
   * 1. Efficiently check api gatekeeper permissions (reuses a single getActions api call for every link).
   * 2. Transform funny action names (some links have non-api-standard actions like "detach" or "copy").
   *
   * @param string $entityName
   * @param string $actionName
   * @return string|null
   */
  private function getPermittedLinkAction(string $entityName, string $actionName): ?string {
    // Load api actions and cache for performance (this function may be called hundreds of times per request)
    if (!isset($this->entityActions[$entityName])) {
      if (!CoreUtil::entityExists($entityName)) {
        return NULL;
      }
      $this->entityActions[$entityName] = [
        'all' => civicrm_api4($entityName, 'getActions', ['checkPermissions' => FALSE])->column('name'),
        'allowed' => civicrm_api4($entityName, 'getActions', ['checkPermissions' => TRUE])->column('name'),
      ];
    }
    // Map CRM_Core_Action names to API action names :/
    $map = [
      'add' => 'create',
    ];
    $actionName = $map[$actionName] ?? $actionName;
    // Action exists and is permitted
    if (in_array($actionName, $this->entityActions[$entityName]['allowed'], TRUE)) {
      return $actionName;
    }
    // Action exists but is not permitted
    elseif (in_array($actionName, $this->entityActions[$entityName]['all'], TRUE)) {
      return NULL;
    }
    // Api action does not exist, so it's a link with a weird action name like "detach".
    // Fall-back on "update"
    elseif (in_array('update', $this->entityActions[$entityName]['allowed'], TRUE)) {
      return 'update';
    }
    // Api action does not exist and user does not have permission to "update".
    return NULL;
  }

  /**
   * Check if a link should be shown based on its conditions.
   *
   * Given a link, check if it is set to be displayed conditionally.
   * If so, evaluate the condition, else return TRUE.
   *
   * @param array $link
   * @param array $data
   * @return bool
   */
  protected function checkLinkConditions(array $link, array $data): bool {
    foreach ($link['conditions'] ?? [] as $condition) {
      if (!$this->checkLinkCondition($condition, $data)) {
        return FALSE;
      }
    }
    return TRUE;
  }

  /**
   * Evaluate a link condition.
   *
   * @param array $condition
   * @param array $data
   * @return bool
   */
  protected function checkLinkCondition(array $condition, array $data): bool {
    if (empty($condition[0]) || empty($condition[1])) {
      return TRUE;
    }
    $op = $condition[1];
    if ($condition[0] === 'check user permission') {
      // No permission == open access
      if (empty($condition[2])) {
        return TRUE;
      }
      $permissions = (array) $condition[2];
      if ($op === 'CONTAINS') {
        // Place conditions in OR array for CONTAINS operator
        $permissions = [$permissions];
      }
      return \CRM_Core_Permission::check($permissions) == ($op !== '!=');
    }
    $field = $this->getField($condition[0]);
    // Handle date/time-based conditionals
    $dataType = $field['data_type'] ?? NULL;
    if (in_array($dataType, ['Timestamp', 'Date'], TRUE) && !empty($condition[2])) {
      $condition[2] = date('Y-m-d H:i:s', strtotime($condition[2]));
    }
    // Convert the conditional value of 'current_domain' into an actual value that filterCompare can work with
    if ((($field['fk_entity'] ?? NULL) === 'Domain') && ($condition[2] ?? '') === 'current_domain') {
      if (str_ends_with($condition[0], ':label') !== FALSE) {
        $condition[2] = \CRM_Core_BAO_Domain::getDomain()->name;
      }
      else {
        $condition[2] = \CRM_Core_Config::domainID();
      }
    }
    return self::filterCompare($data, $condition);
  }

  /**
   * Fills in info about each link in the search display.
   */
  protected function preprocessLinks(): void {
    foreach ($this->display['settings']['columns'] as &$column) {
      if (!empty($column['link'])) {
        $this->preprocessLink($column['link']);
      }
      if (!empty($column['links'])) {
        foreach ($column['links'] as &$link) {
          $this->preprocessLink($link);
        }
      }
    }
    if (!empty($this->display['settings']['toolbar'])) {
      foreach ($this->display['settings']['toolbar'] as &$button) {
        $this->preprocessLink($button);
      }
    }
  }

  /**
   * @param array{path: string, entity: string, action: string, task: string, join: string, target: string, style: string, title: string, text: string, prefix: string, conditions: array} $link
   */
  private function preprocessLink(array &$link): void {
    $link += [
      'path' => '',
      'csrf' => NULL,
      'target' => '',
      'entity' => '',
      'text' => '',
      'title' => '',
      'prefix' => '',
      'key' => '',
      'conditions' => [],
    ];
    $entity = $link['entity'];
    if ($entity && CoreUtil::entityExists($entity)) {
      $idKey = $this->getIdKeyName($entity);
      // Hack to support links to relationships
      if ($entity === 'Relationship') {
        $entity = 'RelationshipCache';
      }
      if (!empty($link['join'])) {
        $link['prefix'] = $link['join'] . '.';
      }
      // Get path from action for non-task links
      if (!empty($link['action']) && empty($link['task'])) {
        $getLinks = civicrm_api4($entity, 'getLinks', [
          'checkPermissions' => FALSE,
          'where' => [
            ['ui_action', '=', $link['action']],
          ],
        ]);
        $link['path'] = $getLinks[0]['path'] ?? NULL;
        $link['conditions'] = array_merge($link['conditions'], $getLinks[0]['conditions'] ?? []);
        // This is a bit clunky, the function_join_field gets un-munged later by $this->getJoinFromAlias()
        if ($this->canAggregate($link['prefix'] . $idKey)) {
          $link['prefix'] = 'GROUP_CONCAT_' . str_replace('.', '_', $link['prefix']);
        }
        if ($link['prefix']) {
          $link['path'] = str_replace('[', '[' . $link['prefix'], $link['path']);
        }
      }
      // Process task links
      elseif (!$link['path'] && !empty($link['task'])) {
        $task = $this->getTask($link['task']);
        $link['conditions'] = array_merge($link['conditions'], $task['conditions'] ?? []);
        // Convert legacy tasks (which have a url)
        if (!empty($task['crmPopup'])) {
          $idField = CoreUtil::getIdFieldName($link['entity']);
          $link['path'] = \CRM_Utils_JS::decode($task['crmPopup']['path']);
          $data = \CRM_Utils_JS::getRawProps($task['crmPopup']['data']);
          // Find the special key that combines selected ids and replace it with id token
          $idsKey = array_search("ids.join(',')", $data);
          unset($data[$idsKey], $link['task']);
          $amp = strpos($link['path'], '?') ? '&' : '?';
          $link['path'] .= $amp . $idField . '=[' . $link['prefix'] . $idKey . ']';
          // Add the rest of the data items
          foreach ($data as $dataKey => $dataRaw) {
            $link['path'] .= '&' . $dataKey . '=' . \CRM_Utils_JS::decode($dataRaw);
          }
        }
        elseif (!empty($task['apiBatch']) || !empty($task['uiDialog'])) {
          if (!strlen($link['title'])) {
            $link['title'] = $task['title'];
          }
          // Fill in the api action if known, for the sake of $this->checkLinkAccess
          $link['action'] = $task['apiBatch']['action'] ?? NULL;
          // Used by inlineEdit action when running inline tasks
          $link['api_params'] = $task['apiBatch']['params'] ?? [];
        }
      }
      $link['key'] = $link['prefix'] . $idKey;
    }
    // Add join prefix to predefined conditions
    foreach ($link['conditions'] as &$condition) {
      if (!empty($condition[0]) && $condition[0] !== 'check user permission') {
        $condition[0] = $link['prefix'] . $condition[0];
      }
    }
    // Backward-compat: search displays created prior to 6.4 only supported 1 condition
    if (!empty($link['condition'])) {
      $link['conditions'][] = $link['condition'];
    }
    unset($link['condition']);
  }

  /**
   * Get fields needed by a link which should be added to the SELECT clause
   *
   * @param array $link
   * @return array
   */
  private function getLinkTokens(array $link): array {
    $tokens = [];
    if (!$link['path'] && !empty($link['task'])) {
      $tokens[] = $link['prefix'] . $this->getIdKeyName($link['entity']);
    }
    foreach ($link['conditions'] ?? [] as $condition) {
      if (!empty($condition[0]) && $condition[0] !== 'check user permission') {
        $tokens[] = $condition[0];
      }
    }
    return array_merge($tokens, $this->getTokens($link['path'] . $link['text'] . $link['title']));
  }

  /**
   * Returns information about a task, but only if user has permission to use it.
   *
   * @param string $taskName
   * @return array|null
   */
  private function getTask(string $taskName): ?array {
    if (!isset($this->tasks)) {
      try {
        $this->tasks = SearchDisplay::getSearchTasks()
          ->setCheckPermissions($this->getCheckPermissions())
          ->setSavedSearch($this->getSavedSearch())
          ->setDisplay($this->getDisplay())
          ->execute()
          ->indexBy('name');
      }
      catch (\CRM_Core_Exception $e) {
        $this->tasks = [];
      }
    }
    return $this->tasks[$taskName] ?? NULL;
  }

  /**
   * @param string $path
   * @param array $query
   * @return string
   */
  private function getUrl(string $path, $query = NULL) {
    if ($path[0] === '/' || str_contains($path, 'http://') || str_contains($path, 'https://')) {
      return $path;
    }

    $flags = NULL;
    // Use absolute urls when downloading spreadsheet
    if ($this->getActionName() === 'download') {
      $flags = 'a';
    }
    return (string) \Civi::url($path, $flags)->addQuery($query);
  }

  /**
   * @param array $column
   * @param array $data
   * @return array{entity: string, action: string, input_type: string, data_type: string, options: bool, serialize: bool, nullable: bool, fk_entity: string, value_key: string, record: array, value_path: string}|null
   */
  protected function formatEditableColumn($column, $data) {
    $editable = $this->getEditableInfo($column);
    $editable['record'] = [];
    // Generate params to edit existing record
    if (!empty($data[$editable['id_path']])) {
      $editable['action'] = 'update';
      $editable['record'][$editable['id_key']] = $data[$editable['id_path']];
      // Ensure field is appropriate to this entity sub-type
      $entityValues = FormattingUtil::filterByPath($data, $editable['id_path'], $editable['id_key']);
      if (!$this->fieldBelongsToEntity($editable['entity'], $editable['value_key'], $entityValues)) {
        return NULL;
      }
    }
    // Generate params to create new record, if applicable
    elseif ($editable['explicit_join'] && !$this->getJoin($editable['explicit_join'])['bridge']) {
      $editable['action'] = 'create';
      $editable['nullable'] = FALSE;
      // Get values for creation from the join clause
      $join = $this->getQuery()->getExplicitJoin($editable['explicit_join']);
      foreach ($join['on'] ?? [] as $clause) {
        if (is_array($clause) && count($clause) === 3 && $clause[1] === '=') {
          // Because clauses are reversible, check both directions to see which side has a fieldName belonging to this join
          foreach ([0 => 2, 2 => 0] as $field => $value) {
            if (str_starts_with($clause[$field], $editable['explicit_join'] . '.')) {
              $fieldName = substr($clause[$field], strlen($editable['explicit_join']) + 1);
              // If the value is a field, get it from the data
              if (isset($data[$clause[$value]])) {
                $editable['record'][$fieldName] = $data[$clause[$value]];
              }
              // If it's a literal bool or number
              elseif (is_bool($clause[$value]) || is_numeric($clause[$value])) {
                $editable['record'][$fieldName] = $clause[$value];
              }
              // If it's a literal string it will be quoted
              elseif (is_string($clause[$value]) && in_array($clause[$value][0], ['"', "'"], TRUE) && substr($clause[$value], -1) === $clause[$value][0]) {
                $editable['record'][$fieldName] = substr($clause[$value], 1, -1);
              }
            }
          }
        }
      }
      // Ensure all required values exist for create action
      $vals = array_keys(array_filter($editable['record']));
      // Ignore pseudoconstant suffixes in field names
      $vals = array_map(fn($v) => explode(':', $v)[0], $vals);
      $vals[] = $editable['value_key'];
      $missingRequiredFields = civicrm_api4($editable['entity'], 'getFields', [
        'action' => 'create',
        'where' => [
          ['type', '=', 'Field'],
          ['required', '=', TRUE],
          ['default_value', 'IS NULL'],
          ['name', 'NOT IN', $vals],
        ],
      ]);
      if ($missingRequiredFields->count() || count($vals) === 1) {
        return NULL;
      }
      $entityValues = $editable['record'];
    }
    // Ensure either the display uses acl_bypass or the current user has access
    if ($editable['record']) {
      if (!empty($this->display['acl_bypass'])) {
        $access = TRUE;
      }
      else {
        $access = civicrm_api4($editable['entity'], 'checkAccess', [
          'action' => $editable['action'],
          'values' => $entityValues,
        ], 0)['access'];
      }
      if ($access) {
        return $editable;
      }
    }
    return NULL;
  }

  /**
   * Check if a field is appropriate for this entity type or sub-type.
   *
   * For example, the 'first_name' field does not belong to Contacts of type Organization.
   * And custom data is sometimes limited to specific contact types, event types, case types, etc.
   *
   * @param string $entityName
   * @param string $fieldName
   * @param array $entityValues
   * @param bool $checkPermissions
   * @return bool
   */
  private function fieldBelongsToEntity($entityName, $fieldName, $entityValues, $checkPermissions = TRUE) {
    try {
      $fields = civicrm_api4($entityName, 'getFields', [
        'checkPermissions' => $checkPermissions,
        'where' => [['name', '=', $fieldName]],
        'values' => $entityValues,
      ]);
      return (bool) $fields->count();
    }
    catch (\CRM_Core_Exception $e) {
      return FALSE;
    }
  }

  /**
   * @param array $column
   *   Column definition
   * @param null|string $key
   *   Internal use only
   * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, nullable: bool, fk_entity: string, value_key: string, value_path: string, id_key: string, id_path: string, explicit_join: string, grouping_fields: array}|null
   */
  protected function getEditableInfo(array $column, ?string $key = NULL) {
    $key ??= $column['key'];
    if (array_key_exists($key, $this->editableInfo)) {
      return $this->editableInfo[$key];
    }
    // Strip pseudoconstant suffix
    [$baseKey] = explode(':', $key);
    $getModeField = $this->getField($key);
    $fieldName = $getModeField['name'] ?? NULL;
    // If field is an implicit join to another entity, use the original fk field
    // UNLESS it's a custom field (which the api treats the same as core fields) or a virtual join like `address_primary.city`
    if (!empty($getModeField['implicit_join']) && empty($getModeField['custom_field_id'])) {
      $baseFieldName = substr($baseKey, 0, -1 - strlen($getModeField['name']));
      $baseField = $this->getField($baseFieldName);
      $baseInfo = $this->getEditableInfo($column, $baseFieldName);
      // Implicit join to real field
      if ($baseField && !empty($baseField['fk_entity']) && $baseField['type'] === 'Field') {
        return $baseInfo;
      }
      elseif ($getModeField) {
        $getModeField['entity'] = $baseField['entity'];
        $getModeField['name'] = $baseKey;
      }
    }
    $result = NULL;
    if ($getModeField) {
      // Reload field with correct action because `$this->getField()` uses 'get' as the action
      $createModeField = civicrm_api4($getModeField['entity'], 'getFields', [
        'select' => ['input_type', 'input_attrs', 'data_type', 'options', 'serialize', 'nullable'],
        'where' => [['name', '=', $fieldName]],
        'checkPermissions' => empty($this->display['acl_bypass']),
        'loadOptions' => ['id', 'name', 'label', 'description', 'color', 'icon'],
        'action' => 'create',
      ])->first() ?? [];
      // Merge with the augmented metadata like `explicit_join`
      $field = $createModeField + $getModeField;
      $idKey = CoreUtil::getIdFieldName($field['entity']);
      $path = (!empty($field['explicit_join']) ? $field['explicit_join'] . '.' : '');
      $idPath = $path . $idKey;
      // Hack to support editing relationships
      if ($field['entity'] === 'RelationshipCache') {
        $field['entity'] = 'Relationship';
        $idPath = $path . 'relationship_id';
      }
      // Allow date/time elements to be represented as date-only
      if ($field['data_type'] === 'Timestamp' && ($column['format'] ?? '') === 'dateformatFull') {
        $field['data_type'] = 'Date';
      }
      $result = [
        'entity' => $field['entity'],
        'input_type' => $field['input_type'],
        'input_attrs' => $field['input_attrs'],
        'data_type' => $field['data_type'],
        'options' => $field['options'],
        'serialize' => !empty($field['serialize']),
        'nullable' => !empty($field['nullable']),
        'fk_entity' => $field['fk_entity'],
        'value_key' => $field['name'],
        'value_path' => $baseKey,
        'id_key' => $idKey,
        'id_path' => $idPath,
        'explicit_join' => $field['explicit_join'],
        'control_field' => $field['input_attrs']['control_field'] ?? NULL,
        'grouping_fields' => [],
      ];
      // With a control field present, options must be loaded dynamically clientside
      // See crmSearchDisplayEditable.loadOptions()
      if ($result['control_field'] && $getModeField['options']) {
        $result['options'] = TRUE;
      }
      // Grouping fields get added to the query so that contact sub-type and entity type (for custom fields)
      // are available to filter fields specific to an entity sub-type. See self::fieldBelongsToEntity()
      if ($field['type'] === 'Custom' || $field['entity'] === 'Contact') {
        $customInfo = \Civi\Api4\Utils\CoreUtil::getCustomGroupExtends($field['entity']);
        foreach ((array) ($customInfo['grouping'] ?? []) as $grouping) {
          $result['grouping_fields'][] = $path . $grouping;
        }
      }
    }
    return $this->editableInfo[$key] = $result;
  }

  /**
   * @param $column
   * @param $data
   * @return array{url: string, width: int, height: int}|NULL
   */
  private function formatImage($column, $data) {
    $tokenExpr = $column['rewrite'] ?: '[' . $column['key'] . ']';
    $url = $this->replaceTokens($tokenExpr, $data, 'url');
    if (!$url && !empty($column['empty_value'])) {
      $url = $this->replaceTokens($column['empty_value'], $data, 'url');
    }
    if (!$url) {
      return NULL;
    }
    return [
      'src' => $url,
      'height' => $column['image']['height'] ?? NULL,
      'width' => $column['image']['width'] ?? NULL,
    ];
  }

  /**
   * @param string $tokenExpr
   * @param array $data
   * @param string $format view|raw|url
   * @return string
   */
  private function replaceTokens($tokenExpr, $data, $format) {
    foreach ($this->getTokens($tokenExpr ?? '') as $token) {
      $val = $data[$token] ?? NULL;
      if (isset($val) && $format === 'view') {
        $dataType = $this->getSelectExpression($token)['dataType'] ?? NULL;
        $val = $this->formatViewValue($token, $val, $data, $dataType);
      }
      $replacement = implode(', ', (array) $val);
      // A missing token value in a url invalidates it
      if ($format === 'url' && (!isset($replacement) || $replacement === '')) {
        return NULL;
      }
      $tokenExpr = str_replace('[' . $token . ']', ($replacement ?? ''), ($tokenExpr ?? ''));
    }
    return $tokenExpr;
  }

  /**
   * Format raw field value according to data type
   * @param string $key
   * @param mixed $rawValue
   * @param array $data
   * @param string $dataType
   * @param string|array|null $format
   * @return array|string
   */
  protected function formatViewValue(string $key, $rawValue, $data, $dataType, $format = NULL) {
    if (is_array($rawValue)) {
      return array_map(function($val) use ($key, $data, $dataType, $format) {
        return $this->formatViewValue($key, $val, $data, $dataType, $format);
      }, $rawValue);
    }

    if (!isset($rawValue) || $rawValue === '') {
      return '';
    }
    // Do not reformat pseudoconstant suffixes
    if (FormattingUtil::getSuffix($key)) {
      return $rawValue;
    }
    $formatted = $rawValue;

    switch ($dataType) {
      case 'Boolean':
        if (is_bool($rawValue)) {
          $formatted = $rawValue ? ts('Yes') : ts('No');
        }
        break;

      case 'Money':
        $currencyField = $this->getCurrencyField($key);
        $currency = is_string($data[$currencyField] ?? NULL) ? $data[$currencyField] : NULL;
        $formatted = \Civi::format()->money($rawValue, $currency);
        break;

      case 'Float':
        $format = $format ?: [];
        // Ignore null values in format array
        $format = array_filter($format, 'is_int');
        $formatted = \CRM_Utils_Number::formatLocaleNumeric($rawValue, NULL, $format);
        break;

      case 'Date':
      case 'Timestamp':
        if ($format) {
          $dateFormat = \Civi::settings()->get($format);
        }
        $formatted = \CRM_Utils_Date::customFormat($rawValue, $dateFormat ?? NULL);
    }

    return $formatted;
  }

  /**
   * Applies supplied filters to the where clause
   */
  protected function applyFilters() {
    // Allow all filters that are included in SELECT clause or are fields on the Afform.
    $fieldFilters = $this->getAfformFilterFields();
    $directiveFilters = $this->getAfformDirectiveFilters();
    $allowedFilters = array_merge($this->getSelectAliases(), array_keys($fieldFilters), $directiveFilters);

    // Ignore empty strings
    $filters = array_filter($this->filters, [$this, 'hasValue']);
    if (!$filters) {
      return;
    }
    // Parse comma-separated values from filters passed through afform variables
    // These values may have come from the url and should be transformed into arrays
    foreach ($directiveFilters as $key) {
      if (!empty($filters[$key]) && is_string($filters[$key]) && strpos($filters[$key], ',')) {
        $filters[$key] = explode(',', $filters[$key]);
      }
    }
    // Add all filters to the WHERE or HAVING clause
    foreach ($filters as $key => $value) {
      $fieldNames = explode(',', $key);
      if (in_array($key, $allowedFilters, TRUE) || !array_diff($fieldNames, $allowedFilters)) {
        $this->applyFilter($fieldNames, $value, $fieldFilters);
      }
    }
    // After adding filters, set filter labels
    // Filter labels are used to set the page title for drilldown forms
    foreach ($filters as $key => $value) {
      if (in_array($key, $directiveFilters, TRUE)) {
        $this->addFilterLabel($key, $value);
      }
    }
  }

  /**
   * Returns an array of field names or aliases + allowed suffixes from the SELECT clause
   * @return string[]
   */
  protected function getSelectAliases() {
    $result = [];
    $selectAliases = array_map(function($select) {
      return array_slice(explode(' AS ', $select), -1)[0];
    }, $this->_apiParams['select']);
    foreach ($selectAliases as $alias) {
      [$alias] = explode(':', $alias);
      $result[] = $alias;
      foreach (['name', 'label', 'abbr'] as $allowedSuffix) {
        $result[] = $alias . ':' . $allowedSuffix;
      }
    }
    return $result;
  }

  /**
   * Transforms the SORT param (which is expected to be an array of arrays)
   * to the ORDER BY clause (which is an associative array of [field => DIR]
   *
   * @return array
   */
  protected function getOrderByFromSort() {
    // Drag-sortable tables have a forced order
    if (!empty($this->display['settings']['draggable'])) {
      return [$this->display['settings']['draggable'] => 'ASC'];
    }

    $defaultSort = $this->display['settings']['sort'] ?? [];
    $currentSort = [];

    // Add requested sort after verifying it corresponds to sortable columns
    foreach ($this->sort as $item) {
      $column = array_column($this->display['settings']['columns'], NULL, 'key')[$item[0]] ?? NULL;
      if ($column && !(isset($column['sortable']) && !$column['sortable'])) {
        $currentSort[] = $item;
      }
    }

    $orderBy = [];
    foreach ($currentSort ?: $defaultSort as $item) {
      // Apply seed to random sorting
      if ($item[0] === 'RAND()' && isset($this->seed)) {
        $item[0] = 'RAND(' . $this->seed . ')';
      }
      // Prevent errors trying to orderBy nonaggregated columns when using groupBy
      if ($this->canAggregate($item[0])) {
        continue;
      }
      $orderBy[$item[0]] = $item[1];
    }
    return $orderBy;
  }

  /**
   * Adds additional fields to the select clause required to render the display
   *
   * @param array $apiParams
   */
  protected function augmentSelectClause(&$apiParams): void {
    $isEditable = FALSE;
    // Don't mess with EntitySets
    if ($this->savedSearch['api_entity'] === 'EntitySet') {
      return;
    }
    // Add `depth_` column for hierarchical entity displays (but not during inline-edit)
    if (!empty($this->display['settings']['hierarchical']) && !is_a($this, 'Civi\Api4\Action\SearchDisplay\InlineEdit')) {
      $this->addSelectExpression('_depth');
      $this->addSelectExpression('_descendents');
    }
    // Add draggable column (typically "weight")
    if (!empty($this->display['settings']['draggable'])) {
      $this->addSelectExpression($this->display['settings']['draggable']);
    }
    // Add parent_field column for tree displays
    if (!empty($this->display['settings']['parent_field'])) {
      $this->addSelectExpression($this->display['settings']['parent_field']);
    }
    // Add style conditions for the display
    foreach ($this->getCssRulesSelect($this->display['settings']['cssRules'] ?? []) as $addition) {
      $this->addSelectExpression($addition);
    }
    $possibleTokens = '';
    foreach ($this->display['settings']['columns'] as $column) {
      // Collect display values in which a token is allowed
      $possibleTokens .= ($column['rewrite'] ?? '');
      $possibleTokens .= ($column['title'] ?? '');
      $possibleTokens .= ($column['empty_value'] ?? '');

      if (!empty($column['key'])) {
        $this->addSelectExpression($column['key']);
      }
      if (!empty($column['link'])) {
        foreach ($this->getLinkTokens($column['link']) as $token) {
          $this->addSelectExpression($token);
        }
      }
      foreach ($column['links'] ?? [] as $link) {
        foreach ($this->getLinkTokens($link) as $token) {
          $this->addSelectExpression($token);
        }
      }

      // Select id, value & grouping for in-place editing
      if (!empty($column['editable'])) {
        $isEditable = TRUE;
        $editable = $this->getEditableInfo($column);
        if ($editable) {
          foreach (array_merge($editable['grouping_fields'], [$editable['value_path'], $editable['id_path']]) as $addition) {
            $this->addSelectExpression($addition);
          }
        }
      }
      // Add style & icon conditions for the column
      foreach ($this->getCssRulesSelect($column['cssRules'] ?? []) as $addition) {
        $this->addSelectExpression($addition);
      }
      foreach ($this->getIconsSelect($column['icons'] ?? []) as $addition) {
        $this->addSelectExpression($addition);
      }
    }

    // Add primary key field if actions, draggable, or editable are enabled
    // (only needed for non-dao entities, as Api4SelectQuery will auto-add the id)
    if (!CoreUtil::isType($this->savedSearch['api_entity'], 'DAOEntity') &&
      ($isEditable  || !empty($this->display['settings']['actions']) || !empty($this->display['settings']['draggable']))
    ) {
      $this->addSelectExpression(CoreUtil::getIdFieldName($this->savedSearch['api_entity']));
    }

    // Add fields referenced via token
    foreach ($this->getTokens($possibleTokens) as $addition) {
      $this->addSelectExpression($addition);
    }

    foreach ($apiParams['select'] as $select) {
      // When selecting monetary fields, also select currency
      $currencyFieldName = $this->getCurrencyField($select);
      // Only select currency field if it doesn't break ONLY_FULL_GROUP_BY
      if ($currencyFieldName && !$this->canAggregate($currencyFieldName)) {
        $this->addSelectExpression($currencyFieldName);
      }
      // Add field dependencies needed to resolve pseudoconstants
      $clause = $this->getSelectExpression($select);
      if ($clause && $clause['expr']->getType() === 'SqlField' && !empty($clause['fields'])) {
        $fieldAlias = array_keys($clause['fields'])[0];
        $field = $clause['fields'][$fieldAlias];
        if (!empty($field['input_attrs']['control_field']) && strpos($fieldAlias, ':')) {
          $prefix = substr($fieldAlias, 0, strrpos($fieldAlias, $field['name']));
          // Don't need to add the field if a suffixed version already exists
          if (!$this->getSelectExpression($prefix . $field['input_attrs']['control_field'] . ':label')) {
            $this->addSelectExpression($prefix . $field['input_attrs']['control_field']);
          }
        }
      }
    }
  }

  /**
   * Return the corresponding currency field if a select expression is monetary
   *
   * @param string $select
   * @return string|null
   */
  protected function getCurrencyField(string $select): ?string {
    // This function is called one or more times per row so cache the results
    if (array_key_exists($select, $this->currencyFields)) {
      return $this->currencyFields[$select];
    }
    $this->currencyFields[$select] = NULL;

    $clause = $this->getSelectExpression($select);
    // Only deal with fields of type money.
    if (!$clause || !$clause['fields'] || $clause['dataType'] !== 'Money') {
      return NULL;
    }

    $moneyFieldAlias = array_keys($clause['fields'])[0];
    $moneyField = $clause['fields'][$moneyFieldAlias];
    $prefix = substr($moneyFieldAlias, 0, strrpos($moneyFieldAlias, $moneyField['name']));

    // Custom fields do their own thing wrt currency
    if ($moneyField['type'] === 'Custom') {
      return NULL;
    }

    // First look for a currency field on the same entity as the money field
    $ownCurrencyField = $this->findCurrencyField($moneyField['entity']);
    if ($ownCurrencyField) {
      return $this->currencyFields[$select] = $prefix . $ownCurrencyField;
    }

    // Next look at the previously-joined entity
    if ($prefix && $this->getQuery()) {
      $parentJoin = $this->getQuery()->getJoinParent(rtrim($prefix, '.'));
      $parentCurrencyField = $parentJoin ? $this->findCurrencyField($this->getQuery()->getExplicitJoin($parentJoin)['entity']) : NULL;
      if ($parentCurrencyField) {
        return $this->currencyFields[$select] = $parentJoin . '.' . $parentCurrencyField;
      }
    }

    // Fall back on the base entity
    $baseCurrencyField = $this->findCurrencyField($this->savedSearch['api_entity']);
    if ($baseCurrencyField) {
      return $this->currencyFields[$select] = $baseCurrencyField;
    }

    // Finally, try adding an implicit join
    // e.g. the LineItem entity can use `contribution_id.currency`
    foreach ($this->findFKFields($moneyField['entity']) as $fieldName => $fkEntity) {
      $joinCurrencyField = $this->findCurrencyField($fkEntity);
      if ($joinCurrencyField) {
        return $this->currencyFields[$select] = $prefix . $fieldName . '.' . $joinCurrencyField;
      }
    }
    return NULL;
  }

  /**
   * Find currency field for an entity.
   *
   * @param string $entityName
   * @return string|null
   */
  private function findCurrencyField(string $entityName): ?string {
    $entityDao = CoreUtil::getInfoItem($entityName, 'dao');
    if ($entityDao) {
      // Check for a pseudoconstant that points to civicrm_currency.
      foreach ($entityDao::getSupportedFields() as $fieldName => $field) {
        if (($field['pseudoconstant']['table'] ?? NULL) === 'civicrm_currency') {
          return $fieldName;
        }
      }
    }
    return NULL;
  }

  /**
   * Return all fields for this entity with a foreign key
   *
   * @param string $entityName
   * @return string[]
   */
  private function findFKFields(string $entityName): array {
    $entityDao = CoreUtil::getInfoItem($entityName, 'dao');
    $fkFields = [];
    if ($entityDao) {
      // Check for a pseudoconstant that points to civicrm_currency.
      foreach ($entityDao::getSupportedFields() as $fieldName => $field) {
        $fkEntity = !empty($field['FKClassName']) ? CoreUtil::getApiNameFromBAO($field['FKClassName']) : NULL;
        if ($fkEntity) {
          $fkFields[$fieldName] = $fkEntity;
        }
      }
    }
    return $fkFields;
  }

  /**
   * @param string $expr
   */
  protected function addSelectExpression(string $expr):void {
    if (!$this->getSelectExpression($expr)) {
      // Tokens for aggregated columns start with 'GROUP_CONCAT_'
      if (str_starts_with($expr, 'GROUP_CONCAT_')) {
        $expr = 'GROUP_CONCAT(UNIQUE ' . $this->getJoinFromAlias(explode('_', $expr, 3)[2]) . ') AS ' . $expr;
      }
      $this->_apiParams['select'][] = $expr;
      // Force-reset cache so it gets rebuilt with the new select param
      $this->_selectClause = NULL;
    }
  }

  /**
   * Given an alias like Contact_Email_01_location_type_id
   * this will return Contact_Email_01.location_type_id
   * @param string $alias
   * @return string
   */
  protected function getJoinFromAlias(string $alias) {
    $result = '';
    foreach ($this->_apiParams['join'] ?? [] as $join) {
      $joinName = explode(' AS ', $join[0])[1];
      if (str_starts_with($alias, $joinName)) {
        $parsed = $joinName . '.' . substr($alias, strlen($joinName) + 1);
        // Ensure we are using the longest match
        if (strlen($parsed) > strlen($result)) {
          $result = $parsed;
        }
      }
    }
    return $result ?: $alias;
  }

  /**
   * Returns a list of afform fields used as search filters
   *
   * Limited to the current display
   *
   * @return string[]
   */
  private function getAfformFilterFields() {
    $afform = $this->loadAfform();
    if ($afform) {
      return array_column(\CRM_Utils_Array::findAll(
        $afform['searchDisplay']['fieldset'],
        ['#tag' => 'af-field']
      ), NULL, 'name');
    }
    return [];
  }

  /**
   * Finds all directive filters and applies the ones with a literal value
   *
   * Returns the list of filters that did not get auto-applied (value was passed via js)
   *
   * @return string[]
   */
  private function getAfformDirectiveFilters() {
    $afform = $this->loadAfform();
    if (!$afform) {
      return [];
    }
    $filterKeys = [];
    // Get filters passed into search display directive from Afform markup
    $filterAttr = $afform['searchDisplay']['filters'] ?? NULL;
    if ($filterAttr && is_string($filterAttr) && $filterAttr[0] === '{') {
      foreach (\CRM_Utils_JS::decode($filterAttr) as $filterKey => $filterVal) {
        // Automatically apply filters from the markup if they have a value
        // Only do this if there's one instance of the display on the form
        if ($afform['searchDisplay']['count'] === 1 && $filterVal !== NULL) {
          unset($this->filters[$filterKey]);
          if ($this->hasValue($filterVal)) {
            $this->applyFilter(explode(',', $filterKey), $filterVal);
          }
        }
        // If it's a javascript variable it will have come back from decode() as NULL;
        // Or if there's more than one instance of the display on the form, they might
        // use different filters.
        // Just whitelist it so the value passed in will be accepted.
        else {
          $filterKeys[] = $filterKey;
        }
      }
    }
    return $filterKeys;
  }

  /**
   * Return afform with name specified in api call.
   *
   * Verifies the searchDisplay is embedded in the afform and the user has permission to view it.
   *
   * @return array|false
   */
  private function loadAfform() {
    // Only attempt to load afform once.
    if ($this->afform && !isset($this->_afform)) {
      $this->_afform = FALSE;
      // Permission checks are enabled in this api call to ensure the user has permission to view the form
      $afform = \Civi\Api4\Afform::get($this->getCheckPermissions())
        ->addWhere('name', '=', $this->afform)
        ->setLayoutFormat('deep')
        ->execute()->first();
      if (empty($afform['layout'])) {
        return FALSE;
      }
      $afform['searchDisplay'] = NULL;
      // Get all search display fieldsets (which will have an empty value for the af-fieldset attribute)
      $fieldsets = \CRM_Utils_Array::findAll($afform['layout'], ['af-fieldset' => '']);
      // As a fallback, search the entire afform in case the search display is not in a fieldset
      $fieldsets['form'] = $afform['layout'];
      // Search for one or more instance of this search display
      foreach ($fieldsets as $key => $fieldset) {
        if ($key === 'form' && $afform['searchDisplay']) {
          // Already found in a fieldset, don't search the whole form
          continue;
        }
        $displays = \CRM_Utils_Array::findAll(
          $fieldset,
          ['#tag' => $this->display['type:name'], 'search-name' => $this->savedSearch['name'], 'display-name' => $this->display['name']]
        );
        if (!$displays) {
          continue;
        }
        // Already found, just increment the count
        if ($afform['searchDisplay']) {
          $afform['searchDisplay']['count'] += count($displays);
        }
        else {
          $afform['searchDisplay'] = $displays[0];
          $afform['searchDisplay']['count'] = count($displays);
          // Set the fieldset for this display (if it is in one and we haven't fallen back to the whole form)
          // TODO: This just uses the first fieldset, but there could be multiple. Potentially could use filters to match it.
          $afform['searchDisplay']['fieldset'] = $key === 'form' ? [] : $fieldset;
        }
      }
      // For security, Afform must contain the search display.
      if (!$afform['searchDisplay']) {
        throw new UnauthorizedException('Afform does not contain search display');
      }
      $this->_afform = $afform;
    }
    return $this->_afform;
  }

  /**
   * Extra calculated fields provided by SearchKit
   * @return array[]
   */
  public static function getPseudoFields(): array {
    return [
      [
        'name' => 'result_row_num',
        'fieldName' => 'result_row_num',
        'title' => ts('Row Number'),
        'label' => ts('Row Number'),
        'description' => ts('Index of each row, starting from 1 on the first page'),
        'type' => 'Pseudo',
        'data_type' => 'Integer',
        'readonly' => TRUE,
      ],
      [
        'name' => 'user_contact_id',
        'fieldName' => 'user_contact_id',
        'title' => ts('Current User ID'),
        'label' => ts('Current User ID'),
        'description' => ts('Contact ID of the current user if logged in'),
        'type' => 'Pseudo',
        'data_type' => 'Integer',
        'readonly' => TRUE,
      ],
      [
        'name' => 'CURDATE()',
        'fieldName' => 'CURDATE()',
        'title' => ts('Current Date'),
        'label' => ts('Current Date'),
        'description' => ts('System date at the moment the search is run'),
        'type' => 'Pseudo',
        'data_type' => 'Date',
        'readonly' => TRUE,
      ],
      [
        'name' => 'NOW()',
        'fieldName' => 'NOW()',
        'title' => ts('Current Date + Time'),
        'label' => ts('Current Date + Time'),
        'description' => ts('System date and time at the moment the search is run'),
        'type' => 'Pseudo',
        'data_type' => 'Timestamp',
        'readonly' => TRUE,
      ],
    ];
  }

  /**
   * Sets $this->filterLabels to provide contextual titles to search Afforms
   *
   * @param $fieldName
   * @param $value
   * @throws \CRM_Core_Exception
   * @throws \Civi\API\Exception\NotImplementedException
   */
  private function addFilterLabel($fieldName, $value) {
    $field = $this->getField($fieldName);
    if (!$field || !$value) {
      return;
    }
    $idField = CoreUtil::getIdFieldName($field['entity']);
    if ($field['name'] === $idField) {
      $field['fk_entity'] = $field['entity'];
    }
    else {
      // Reload field with options and any dynamic FKs based on values (e.g. entity_table)
      $field = civicrm_api4($field['entity'], 'getFields', [
        'loadOptions' => TRUE,
        'checkPermissions' => FALSE,
        'values' => $this->getWhereClauseValues(),
        'where' => [['name', '=', $field['name']]],
      ])->first();
    }
    if (!empty($field['options'])) {
      foreach ((array) $value as $val) {
        if (!empty($field['options'][$val])) {
          $this->filterLabels[] = $field['options'][$val];
        }
      }
    }
    elseif (!empty($field['fk_entity'])) {
      $idField = CoreUtil::getIdFieldName($field['fk_entity']);
      $labelField = CoreUtil::getInfoItem($field['fk_entity'], 'label_field');
      if ($labelField) {
        $records = civicrm_api4($field['fk_entity'], 'get', [
          'checkPermissions' => $this->checkPermissions && empty($this->display['acl_bypass']),
          'where' => [[$idField, 'IN', (array) $value]],
          'select' => [$labelField],
        ]);
        foreach ($records as $record) {
          if (isset($record[$labelField])) {
            $this->filterLabels[] = $record[$labelField];
          }
        }
      }
    }
  }

  /**
   * Returns any key/value pairs in the WHERE clause (those using the `=` operator)
   *
   * @return array
   */
  private function getWhereClauseValues(): array {
    $values = [];
    foreach ($this->_apiParams['where'] as $clause) {
      if (count($clause) > 2 && $clause[1] === '=' && empty($clause[3]) && !str_contains('(', $clause[0])) {
        $values[$clause[0]] = $clause[2];
      }
    }
    return $values;
  }

  protected function getRowKeyName(): string {
    // If grouping by a primary key, use that field
    $groupBy = $this->savedSearch['api_params']['groupBy'] ?? [];
    foreach ($groupBy as $fieldName) {
      $field = $this->getField($fieldName);
      if ($field && (CoreUtil::getIdFieldName($field['entity']) === $field['name'])) {
        return $fieldName;
      }
    }
    // Use primary key of main entity
    $entityName = $this->savedSearch['api_entity'];
    return CoreUtil::getIdFieldName($entityName);
  }

  /**
   * Given an entity name, returns the data fieldName used to identify it.
   * @param string|null $entityName
   * @return string
   */
  protected function getIdKeyName(?string $entityName) {
    // Hack to support links to relationships
    if ($entityName === 'Relationship') {
      return 'relationship_id';
    }
    return CoreUtil::getIdFieldName($entityName);
  }

  /**
   * Get data from the where/having clauses, useful for inferring values to create a new entity
   *
   * If $this->applyFilters has already run, it will include data from filters
   *
   * @return array
   * @throws \CRM_Core_Exception
   */
  public function getQueryData(): array {
    // First pass: gather raw data from the where & having clauses
    $data = [];
    foreach (array_merge($this->_apiParams['where'], $this->_apiParams['having'] ?? []) as $clause) {
      if ($clause[1] === '=' || $clause[1] === 'IN') {
        $data[$clause[0]] = $clause[2];
      }
    }
    // Second pass: format values (because data from first pass could be useful to FormattingUtil)
    foreach ($this->_apiParams['where'] as $clause) {
      if ($clause[1] === '=' || $clause[1] === 'IN') {
        [$fieldPath] = explode(':', $clause[0]);
        $fieldSpec = $this->getField($fieldPath);
        $data[$fieldPath] = $clause[2];
        if ($fieldSpec) {
          FormattingUtil::formatInputValue($data[$fieldPath], $clause[0], $fieldSpec, $data, $clause[1]);
        }
      }
    }
    return $data;
  }

}
