<?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       |
 +--------------------------------------------------------------------+
 */

namespace Civi\Api4\Generic;

use Civi\Api4\Utils\CoreUtil;
use Civi\Core\Event\GenericHookEvent;

/**
 * Retrieve $ENTITIES for an autocomplete form field.
 *
 * @since 5.54
 * @method $this setInput(string $input) Set input term.
 * @method string getInput()
 * @method $this setIds(array $ids) Set array of ids.
 * @method array getIds()
 * @method $this setPage(int $page) Set current page.
 * @method array getPage()
 * @method $this setFormName(string $formName) Set formName.
 * @method string getFormName()
 * @method $this setFieldName(string $fieldName) Set fieldName.
 * @method string getFieldName()
 * @method $this setKey(string $key) Set keyField used as unique identifier.
 * @method string getKey()
 * @method $this setFilters(array $filters)
 * @method array getFilters()
 */
class AutocompleteAction extends AbstractAction {
  use Traits\SavedSearchInspectorTrait;
  use Traits\GetSetValueTrait;

  /**
   * Autocomplete search input for search mode
   *
   * @var string
   */
  protected $input = '';

  /**
   * Array of ids for render mode
   *
   * @var array
   */
  protected $ids;

  /**
   * @var int
   */
  protected $page = 1;

  /**
   * Name of SavedSearch to use for filtering.
   * @var string|array
   */
  protected $savedSearch;

  /**
   * 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;

  /**
   * @var string
   */
  protected $formName;

  /**
   * @var string
   */
  protected $fieldName;

  /**
   * Unique identifier to be returned as key (typically `id` or `name`)
   *
   * @var string
   */
  protected $key;

  /**
   * Known entity values.
   *
   * Value will be populated by the form based on data entered at the time.
   * They can be used by hooks for contextual filtering.
   *
   * Format: [fieldName => value][]
   * @var array
   */
  protected $values = [];

  /**
   * Search conditions that will be automatically added to the WHERE or HAVING clauses
   *
   * Format: [fieldName => value][]
   * @var array
   */
  public $filters = [];

  /**
   * Filters set programmatically by `civi.api.prepare` listener. Automatically trusted.
   *
   * Format: [fieldName => value][]
   * @var array
   */
  private $trustedFilters = [];

  /**
   * @var string
   * @see \Civi\Api4\Generic\Traits\SavedSearchInspectorTrait::loadSearchDisplay
   */
  protected $_displayType = 'autocomplete';

  /**
   * Fetch results.
   *
   * @param \Civi\Api4\Generic\Result $result
   */
  public function _run(Result $result) {
    $this->checkPermissionToLoadSearch();

    $entityName = $this->getEntityName();

    if (!$this->savedSearch) {
      $this->savedSearch = ['api_entity' => $entityName];
      // Allow the default search to be modified
      \Civi::dispatcher()->dispatch('civi.search.autocompleteDefault', GenericHookEvent::create([
        'savedSearch' => &$this->savedSearch,
        'formName' => $this->formName,
        'fieldName' => $this->fieldName,
        'filters' => $this->filters,
      ]));
    }
    $this->loadSavedSearch();
    $this->loadSearchDisplay();

    // Pass-through this parameter
    $this->display['acl_bypass'] = !$this->getCheckPermissions();

    $keyField = $this->getKeyField();
    $displayFields = $this->getDisplayFields();
    $this->augmentSelectClause($keyField, $displayFields);

    // Render mode: fetch by id
    if ($this->ids) {
      $this->addFilter($keyField, ['IN' => $this->ids]);
      unset($this->display['settings']['pager']);
      $return = NULL;
    }
    // Search mode: fetch a page of results based on input
    else {
      // Default search and sort field
      $labelField = $this->display['settings']['columns'][0]['key'];
      $primaryKeys = CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'primary_key');
      $this->display['settings'] += [
        'sort' => [$labelField, 'ASC'],
      ];
      // Always search on the first line of the display
      $searchFields = [$labelField];
      // If input is an integer...
      $searchById = \CRM_Utils_Rule::positiveInteger($this->input) &&
        // ...and there is exactly one primary key (e.g. EntitySet has zero, others might have compound keys)
        count($primaryKeys) === 1 &&
        // ...and the primary key field is type Integer (e.g. Afform.name is a String)
        ($this->getField($primaryKeys[0])['data_type'] ?? NULL) === 'Integer';
      // ...then search by primary key on first page
      $initialSearchById = $searchById && $this->page == 1;
      if ($initialSearchById) {
        $searchFields = $primaryKeys;
      }
      // For subsequent pages when searching by id, subtract the "extra" first page
      elseif ($searchById && $this->page > 1) {
        $this->page -= 1;
        // Record with that id was already returned on page one so exclude it from subsequent pages
        $this->savedSearch['api_params']['where'][] = [$primaryKeys[0], '!=', $this->input];
      }
      // If first line uses a rewrite, search on those fields too
      if (!$initialSearchById && !empty($this->display['settings']['columns'][0]['rewrite'])) {
        $searchFields = array_merge($searchFields, $this->getTokens($this->display['settings']['columns'][0]['rewrite']));
      }
      $this->display['settings']['limit'] ??= \Civi::settings()->get('search_autocomplete_count');
      $this->display['settings']['pager'] = [];
      $return = 'scroll:' . $this->page;
      // SearchKit treats comma-separated fieldnames as OR clauses
      $this->addFilter(implode(',', array_unique($searchFields)), $this->input);
    }

    $apiResult = \Civi\Api4\SearchDisplay::run(FALSE)
      ->setSavedSearch($this->savedSearch)
      ->setDisplay($this->display)
      ->setFilters($this->filters)
      ->setReturn($return)
      ->execute();

    foreach ($apiResult as $row) {
      $item = [
        'id' => $row['data'][$keyField],
        'label' => $row['columns'][0]['val'],
        'icon' => $row['columns'][0]['icons']['left'][0] ?? NULL,
        'description' => [],
      ];
      foreach (array_slice($row['columns'], 1) as $col) {
        $item['description'][] = $col['val'];
      }
      foreach ($this->display['settings']['extra'] ?? [] as $name => $key) {
        $item[$key] = $row['data'][$name] ?? $item[$key] ?? NULL;
      }
      $result[] = $item;
    }
    $result->setCountMatched($apiResult->count());
    if (!empty($initialSearchById)) {
      // Trigger "more results" after searching by exact id
      $result->setCountMatched($apiResult->count() + 1);
    }
  }

  /**
   * Method for `civi.api.prepare` listener to add a trusted filter.
   *
   * @param string $fieldName
   * @param mixed $value
   */
  public function addFilter(string $fieldName, $value) {
    $this->filters[$fieldName] = $value;
    $this->trustedFilters[$fieldName] = $value;
  }

  /**
   * Gather all fields used by the display
   *
   * @return array
   */
  private function getDisplayFields() {
    $fields = [];
    foreach ($this->display['settings']['columns'] as $column) {
      if ($column['type'] === 'field') {
        $fields[] = $column['key'];
      }
      if (!empty($column['rewrite'])) {
        $fields = array_merge($fields, $this->getTokens($column['rewrite']));
      }
    }
    return array_unique($fields);
  }

  /**
   * Ensure SELECT param includes all display fields & trusted filters
   *
   * @param string $idField
   * @param array $displayFields
   */
  private function augmentSelectClause(string $idField, array $displayFields) {
    // Don't mess with aggregated queries
    if ($this->savedSearch['api_entity'] === 'EntitySet' || !empty($this->savedSearch['api_params']['groupBy'])) {
      return;
    }
    // Original select params. Key by alias to avoid duplication.
    $originalSelect = [];
    foreach ($this->savedSearch['api_params']['select'] ?? [] as $item) {
      $alias = explode(' AS ', $item)[1] ?? $item;
      $originalSelect[$alias] = $item;
    }
    // Add any missing fields which should be selected
    $additions = array_merge([$idField], $displayFields);
    // Add trustedFilters to the SELECT clause so that SearchDisplay::run will trust them
    foreach ($this->trustedFilters as $fields => $val) {
      $additions = array_merge($additions, explode(',', $fields));
    }
    // Add 'extra' fields defined by the display
    $additions = array_merge($additions, array_keys($this->display['settings']['extra'] ?? []));
    // Add 'sort' fields
    $additions = array_merge($additions, array_column($this->display['settings']['sort'] ?? [], 0));

    // Key by field name and combine with original SELECT
    $additions = array_unique($additions);
    $additions = array_combine($additions, $additions);

    // Maintain original order (important when using UNIONs in the query)
    $this->savedSearch['api_params']['select'] = array_values($originalSelect + $additions);
  }

  /**
   * Get the field by which results will be keyed (typically `id` unless $this->key is set).
   *
   * If $this->key param is set, it will allow it ONLY if the field is a unique index on the entity.
   * This is a security measure. Allowing any value could give access to potentially sensitive data.
   *
   * @return string
   */
  private function getKeyField() {
    $entityName = $this->savedSearch['api_entity'];
    if ($this->key && in_array($this->key, CoreUtil::getInfoItem($entityName, 'match_fields') ?? [], TRUE)) {
      return $this->key;
    }
    return $this->display['settings']['keyField'] ?? CoreUtil::getIdFieldName($entityName);
  }

  /**
   * @return array
   */
  public function getPermissions() {
    // Permissions for this action are checked internally
    return [];
  }

}
