<?php

/**
 * Class representing a search index.
 */
class SearchApiIndex extends Entity {

  // Cache values, set when the corresponding methods are called for the first
  // time.

  /**
   * Cached return value of datasource().
   *
   * @var SearchApiDataSourceControllerInterface
   */
  protected $datasource = NULL;

  /**
   * Cached return value of server().
   *
   * @var SearchApiServer
   */
  protected $server_object = NULL;

  /**
   * All enabled data alterations for this index.
   *
   * @var array
   */
  protected $callbacks = NULL;

  /**
   * All enabled processors for this index.
   *
   * @var array
   */
  protected $processors = NULL;

  /**
   * The properties added by data alterations on this index.
   *
   * @var array
   */
  protected $added_properties = NULL;

  /**
   * An array containing two arrays.
   *
   * At index 0, all fulltext fields of this index. At index 1, all indexed
   * fulltext fields of this index.
   *
   * @var array
   */
  protected $fulltext_fields = array();

  // Database values that will be set when object is loaded.

  /**
   * An integer identifying the index.
   * Immutable.
   *
   * @var integer
   */
  public $id;

  /**
   * A name to be displayed for the index.
   *
   * @var string
   */
  public $name;

  /**
   * The machine name of the index.
   * Immutable.
   *
   * @var string
   */
  public $machine_name;

  /**
   * A string describing the index' use to users.
   *
   * @var string
   */
  public $description;

  /**
   * The machine_name of the server with which data should be indexed.
   *
   * @var string
   */
  public $server;

  /**
   * The type of items stored in this index.
   * Immutable.
   *
   * @var string
   */
  public $item_type;

  /**
   * An array of options for configuring this index. The layout is as follows:
   * - cron_limit: The maximum number of items to be indexed per cron batch.
   * - index_directly: Boolean setting whether entities are indexed immediately
   *   after they are created or updated.
   * - fields: An array of all indexed fields for this index. Keys are the field
   *   identifiers, the values are arrays for specifying the field settings. The
   *   structure of those arrays looks like this:
   *   - type: The type set for this field. One of the types returned by
   *     search_api_default_field_types().
   *   - real_type: (optional) If a custom data type was selected for this
   *     field, this type will be stored here, and "type" contain the fallback
   *     default data type.
   *   - boost: (optional) A boost value for terms found in this field during
   *     searches. Usually only relevant for fulltext fields. Defaults to 1.0.
   *   - entity_type (optional): If set, the type of this field is really an
   *     entity. The "type" key will then just contain the primitive data type
   *     of the ID field, meaning that servers will ignore this and merely index
   *     the entity's ID. Components displaying this field, though, are advised
   *     to use the entity label instead of the ID.
   * - additional fields: An associative array with keys and values being the
   *   field identifiers of related entities whose fields should be displayed.
   * - data_alter_callbacks: An array of all data alterations available. Keys
   *   are the alteration identifiers, the values are arrays containing the
   *   settings for that data alteration. The inner structure looks like this:
   *   - status: Boolean indicating whether the data alteration is enabled.
   *   - weight: Used for sorting the data alterations.
   *   - settings: Alteration-specific settings, configured via the alteration's
   *     configuration form.
   * - processors: An array of all processors available for the index. The keys
   *   are the processor identifiers, the values are arrays containing the
   *   settings for that processor. The inner structure looks like this:
   *   - status: Boolean indicating whether the processor is enabled.
   *   - weight: Used for sorting the processors.
   *   - settings: Processor-specific settings, configured via the processor's
   *     configuration form.
   *
   * @var array
   */
  public $options = array();

  /**
   * A flag indicating whether this index is enabled.
   *
   * @var integer
   */
  public $enabled = 1;

  /**
   * A flag indicating whether to write to this index.
   *
   * @var integer
   */
  public $read_only = 0;

  /**
   * Constructor as a helper to the parent constructor.
   */
  public function __construct(array $values = array()) {
    parent::__construct($values, 'search_api_index');
  }

  /**
   * Execute necessary tasks for a newly created index.
   */
  public function postCreate() {
    if ($this->enabled) {
      $this->queueItems();
    }
    $server = $this->server();
    if ($server) {
      // Tell the server about the new index.
      if ($server->enabled) {
        $server->addIndex($this);
      }
      else {
        $tasks = variable_get('search_api_tasks', array());
        // When we add or remove an index, we can ignore all other tasks.
        $tasks[$server->machine_name][$this->machine_name] = array('add');
        variable_set('search_api_tasks', $tasks);
      }
    }
  }

  /**
   * Execute necessary tasks when the index is removed from the database.
   */
  public function postDelete() {
    if ($server = $this->server()) {
      if ($server->enabled) {
        $server->removeIndex($this);
      }
      else {
        $tasks = variable_get('search_api_tasks', array());
        $tasks[$server->machine_name][$this->machine_name] = array('remove');
        variable_set('search_api_tasks', $tasks);
      }
    }

    // Stop tracking entities for indexing.
    $this->dequeueItems();
  }

  /**
   * Record entities to index.
   */
  public function queueItems() {
    if (!$this->read_only) {
      $this->datasource()->startTracking(array($this));
    }
  }

  /**
   * Remove all records of entities to index.
   */
  public function dequeueItems() {
    $this->datasource()->stopTracking(array($this));
    _search_api_empty_cron_queue($this);
  }

  /**
   * Saves this index to the database, either creating a new record or updating
   * an existing one.
   *
   * @return
   *   Failure to save the index will return FALSE. Otherwise, SAVED_NEW or
   *   SAVED_UPDATED is returned depending on the operation performed. $this->id
   *   will be set if a new index was inserted.
   */
  public function save() {
    if (empty($this->description)) {
      $this->description = NULL;
    }
    if (empty($this->server)) {
      $this->server = NULL;
      $this->enabled = FALSE;
    }
    // This will also throw an exception if the server doesn't exist – which is good.
    elseif (!$this->server(TRUE)->enabled) {
      $this->enabled = FALSE;
    }

    return parent::save();
  }

  /**
   * Helper method for updating entity properties.
   *
   * NOTE: You shouldn't change any properties of this object before calling
   * this method, as this might lead to the fields not being saved correctly.
   *
   * @param array $fields
   *   The new field values.
   *
   * @return
   *   SAVE_UPDATED on success, FALSE on failure, 0 if the fields already had
   *   the specified values.
   */
  public function update(array $fields) {
    $changeable = array('name' => 1, 'enabled' => 1, 'description' => 1, 'server' => 1, 'options' => 1, 'read_only' => 1);
    $changed = FALSE;
    foreach ($fields as $field => $value) {
      if (isset($changeable[$field]) && $value !== $this->$field) {
        $this->$field = $value;
        $changed = TRUE;
      }
    }

    // If there are no new values, just return 0.
    if (!$changed) {
      return 0;
    }

    // Reset the index's internal property cache to correctly incorporate new
    // settings.
    $this->resetCaches();

    return $this->save();
  }

  /**
   * Schedules this search index for re-indexing.
   *
   * @return
   *   TRUE on success, FALSE on failure.
   */
  public function reindex() {
    if (!$this->server || $this->read_only) {
      return TRUE;
    }
    _search_api_index_reindex($this);
    module_invoke_all('search_api_index_reindex', $this, FALSE);
    return TRUE;
  }

  /**
   * Clears this search index and schedules all of its items for re-indexing.
   *
   * @return
   *   TRUE on success, FALSE on failure.
   */
  public function clear() {
    if (!$this->server || $this->read_only) {
      return TRUE;
    }

    $server = $this->server();
    if ($server->enabled) {
      $server->deleteItems('all', $this);
    }
    else {
      $tasks = variable_get('search_api_tasks', array());
      // If the index was cleared or newly added since the server was last enabled, we don't need to do anything.
      if (!isset($tasks[$server->machine_name][$this->machine_name])
          || (array_search('add', $tasks[$server->machine_name][$this->machine_name]) === FALSE
              && array_search('clear', $tasks[$server->machine_name][$this->machine_name]) === FALSE)) {
        $tasks[$server->machine_name][$this->machine_name][] = 'clear';
        variable_set('search_api_tasks', $tasks);
      }
    }

    _search_api_index_reindex($this);
    module_invoke_all('search_api_index_reindex', $this, TRUE);
    return TRUE;
  }

  /**
   * Magic method for determining which fields should be serialized.
   *
   * Don't serialize properties that are basically only caches.
   *
   * @return array
   *   An array of properties to be serialized.
   */
  public function __sleep() {
    $ret = get_object_vars($this);
    unset($ret['server_object'], $ret['datasource'], $ret['processors'], $ret['added_properties'], $ret['fulltext_fields']);
    return array_keys($ret);
  }

  /**
   * Get the controller object of the data source used by this index.
   *
   * @throws SearchApiException
   *   If the specified item type or data source doesn't exist or is invalid.
   *
   * @return SearchApiDataSourceControllerInterface
   *   The data source controller for this index.
   */
  public function datasource() {
    if (!isset($this->datasource)) {
      $this->datasource = search_api_get_datasource_controller($this->item_type);
    }
    return $this->datasource;
  }

  /**
   * Get the server this index lies on.
   *
   * @param $reset
   *   Whether to reset the internal cache. Set to TRUE when the index' $server
   *   property has just changed.
   *
   * @throws SearchApiException
   *   If $this->server is set, but no server with that machine name exists.
   *
   * @return SearchApiServer
   *   The server associated with this index, or NULL if this index currently
   *   doesn't lie on a server.
   */
  public function server($reset = FALSE) {
    if (!isset($this->server_object) || $reset) {
      $this->server_object = $this->server ? search_api_server_load($this->server) : FALSE;
      if ($this->server && !$this->server_object) {
        throw new SearchApiException(t('Unknown server @server specified for index @name.', array('@server' => $this->server, '@name' => $this->machine_name)));
      }
    }
    return $this->server_object ? $this->server_object : NULL;
  }

  /**
   * Create a query object for this index.
   *
   * @param $options
   *   Associative array of options configuring this query. See
   *   SearchApiQueryInterface::__construct().
   *
   * @throws SearchApiException
   *   If the index is currently disabled.
   *
   * @return SearchApiQueryInterface
   *   A query object for searching this index.
   */
  public function query($options = array()) {
    if (!$this->enabled) {
      throw new SearchApiException(t('Cannot search on a disabled index.'));
    }
    return $this->server()->query($this, $options);
  }


  /**
   * Indexes items on this index. Will return an array of IDs of items that
   * should be marked as indexed – i.e., items that were either rejected by a
   * data-alter callback or were successfully indexed.
   *
   * @param array $items
   *   An array of items to index.
   *
   * @return array
   *   An array of the IDs of all items that should be marked as indexed.
   */
  public function index(array $items) {
    if ($this->read_only) {
      return array();
    }
    if (!$this->enabled) {
      throw new SearchApiException(t("Couldn't index values on '@name' index (index is disabled)", array('@name' => $this->name)));
    }
    if (empty($this->options['fields'])) {
      throw new SearchApiException(t("Couldn't index values on '@name' index (no fields selected)", array('@name' => $this->name)));
    }
    $fields = $this->options['fields'];
    $custom_type_fields = array();
    foreach ($fields as $field => $info) {
      if (isset($info['real_type'])) {
        $custom_type = search_api_extract_inner_type($info['real_type']);
        if ($this->server()->supportsFeature('search_api_data_type_' . $custom_type)) {
          $fields[$field]['type'] = $info['real_type'];
          $custom_type_fields[$custom_type][$field] = search_api_list_nesting_level($info['real_type']);
        }
      }
    }
    if (empty($fields)) {
      throw new SearchApiException(t("Couldn't index values on '@name' index (no fields selected)", array('@name' => $this->name)));
    }

    // Mark all items that are rejected as indexed.
    $ret = array_keys($items);
    drupal_alter('search_api_index_items', $items, $this);
    if ($items) {
      $this->dataAlter($items);
    }
    $ret = array_diff($ret, array_keys($items));

    // Items that are rejected should also be deleted from the server.
    if ($ret) {
      $this->server()->deleteItems($ret, $this);
    }
    if (!$items) {
      return $ret;
    }

    $data = array();
    foreach ($items as $id => $item) {
      $data[$id] = search_api_extract_fields($this->entityWrapper($item), $fields);
      unset($items[$id]);
      foreach ($custom_type_fields as $type => $type_fields) {
        $info = search_api_get_data_type_info($type);
        if (isset($info['conversion callback']) && is_callable($info['conversion callback'])) {
          $callback = $info['conversion callback'];
          foreach ($type_fields as $field => $nesting_level) {
            if (isset($data[$id][$field]['value'])) {
              $value = $data[$id][$field]['value'];
              $original_type = $data[$id][$field]['original_type'];
              $data[$id][$field]['value'] = _search_api_convert_custom_type($callback, $value, $original_type, $type, $nesting_level);
            }
          }
        }
      }
    }

    $this->preprocessIndexItems($data);

    return array_merge($ret, $this->server()->indexItems($this, $data));
  }

  /**
   * Calls data alteration hooks for a set of items, according to the index
   * options.
   *
   * @param array $items
   *   An array of items to be altered.
   *
   * @return SearchApiIndex
   *   The called object.
   */
  public function dataAlter(array &$items) {
    // First, execute our own search_api_language data alteration.
    foreach ($items as &$item) {
      $item->search_api_language = isset($item->language) ? $item->language : LANGUAGE_NONE;
    }

    foreach ($this->getAlterCallbacks() as $callback) {
      $callback->alterItems($items);
    }

    return $this;
  }

  /**
   * Property info alter callback that adds the infos of the properties added by
   * data alter callbacks.
   *
   * @param EntityMetadataWrapper $wrapper
   *   The wrapped data.
   * @param $property_info
   *   The original property info.
   *
   * @return array
   *   The altered property info.
   */
  public function propertyInfoAlter(EntityMetadataWrapper $wrapper, array $property_info) {
    if (entity_get_property_info($wrapper->type())) {
      // Overwrite the existing properties with the list of properties including
      // all fields regardless of the used bundle.
      $property_info['properties'] = entity_get_all_property_info($wrapper->type());
    }

    if (!isset($this->added_properties)) {
      $this->added_properties = array(
        'search_api_language' => array(
          'label' => t('Item language'),
          'description' => t("A field added by the search framework to let components determine an item's language. Is always indexed."),
          'type' => 'token',
          'options list' => 'entity_metadata_language_list',
        ),
      );
      // We use the reverse order here so the hierarchy for overwriting property infos is the same
      // as for actually overwriting the properties.
      foreach (array_reverse($this->getAlterCallbacks()) as $callback) {
        $props = $callback->propertyInfo();
        if ($props) {
          $this->added_properties += $props;
        }
      }
    }
    // Let fields added by data-alter callbacks override default fields.
    $property_info['properties'] = array_merge($property_info['properties'], $this->added_properties);

    return $property_info;
  }

   /**
   * Fills the $processors array for use by the pre-/postprocessing functions.
   *
   * @return SearchApiIndex
   *   The called object.
   * @return array
   *   All enabled callbacks for this index, as SearchApiAlterCallbackInterface
   *   objects.
   */
  protected function getAlterCallbacks() {
    if (isset($this->callbacks)) {
      return $this->callbacks;
    }

    $this->callbacks = array();
    if (empty($this->options['data_alter_callbacks'])) {
      return $this->callbacks;
    }
    $callback_settings = $this->options['data_alter_callbacks'];
    $infos = search_api_get_alter_callbacks();

    foreach ($callback_settings as $id => $settings) {
      if (empty($settings['status'])) {
        continue;
      }
      if (empty($infos[$id]) || !class_exists($infos[$id]['class'])) {
        watchdog('search_api', t('Undefined data alteration @class specified in index @name', array('@class' => $id, '@name' => $this->name)), NULL, WATCHDOG_WARNING);
        continue;
      }
      $class = $infos[$id]['class'];
      $callback = new $class($this, empty($settings['settings']) ? array() : $settings['settings']);
      if (!($callback instanceof SearchApiAlterCallbackInterface)) {
        watchdog('search_api', t('Unknown callback class @class specified for data alteration @name', array('@class' => $class, '@name' => $id)), NULL, WATCHDOG_WARNING);
        continue;
      }

      $this->callbacks[$id] = $callback;
    }
    return $this->callbacks;
  }

  /**
   * @return array
   *   All enabled processors for this index, as SearchApiProcessorInterface
   *   objects.
   */
  protected function getProcessors() {
    if (isset($this->processors)) {
      return $this->processors;
    }

    $this->processors = array();
    if (empty($this->options['processors'])) {
      return $this->processors;
    }
    $processor_settings = $this->options['processors'];
    $infos = search_api_get_processors();

    foreach ($processor_settings as $id => $settings) {
      if (empty($settings['status'])) {
        continue;
      }
      if (empty($infos[$id]) || !class_exists($infos[$id]['class'])) {
        watchdog('search_api', t('Undefined processor @class specified in index @name', array('@class' => $id, '@name' => $this->name)), NULL, WATCHDOG_WARNING);
        continue;
      }
      $class = $infos[$id]['class'];
      $processor = new $class($this, isset($settings['settings']) ? $settings['settings'] : array());
      if (!($processor instanceof SearchApiProcessorInterface)) {
        watchdog('search_api', t('Unknown processor class @class specified for processor @name', array('@class' => $class, '@name' => $id)), NULL, WATCHDOG_WARNING);
        continue;
      }

      $this->processors[$id] = $processor;
    }
    return $this->processors;
  }

  /**
   * Preprocess data items for indexing. Data added by data alter callbacks will
   * be available on the items.
   *
   * Typically, a preprocessor will execute its preprocessing (e.g. stemming,
   * n-grams, word splitting, stripping stop words, etc.) only on the items'
   * fulltext fields. Other fields should usually be left untouched.
   *
   * @param array $items
   *   An array of items to be preprocessed for indexing.
   *
   * @return SearchApiIndex
   *   The called object.
   */
  public function preprocessIndexItems(array &$items) {
    foreach ($this->getProcessors() as $processor) {
      $processor->preprocessIndexItems($items);
    }
    return $this;
  }


  /**
   * Preprocess a search query.
   *
   * The same applies as when preprocessing indexed items: typically, only the
   * fulltext search keys should be processed, queries on specific fields should
   * usually not be altered.
   *
   * @param SearchApiQuery $query
   *   The object representing the query to be executed.
   *
   * @return SearchApiIndex
   *   The called object.
   */
  public function preprocessSearchQuery(SearchApiQuery $query) {
    foreach ($this->getProcessors() as $processor) {
      $processor->preprocessSearchQuery($query);
    }
    return $this;
  }

  /**
   * Postprocess search results before display.
   *
   * If a class is used for both pre- and post-processing a search query, the
   * same object will be used for both calls (so preserving some data or state
   * locally is possible).
   *
   * @param array $response
   *   An array containing the search results. See
   *   SearchApiServiceInterface->search() for the detailed format.
   * @param SearchApiQuery $query
   *   The object representing the executed query.
   *
   * @return SearchApiIndex
   *   The called object.
   */
  public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
    // Postprocessing is done in exactly the opposite direction than preprocessing.
    foreach (array_reverse($this->getProcessors()) as $processor) {
      $processor->postprocessSearchResults($response, $query);
    }
    return $this;
  }

  /**
   * Returns a list of all known fields for this index.
   *
   * @param $only_indexed (optional)
   *   Return only indexed fields, not all known fields. Defaults to TRUE.
   * @param $get_additional (optional)
   *   Return not only known/indexed fields, but also related entities whose
   *   fields could additionally be added to the index.
   *
   * @return array
   *   An array of all known fields for this index. Keys are the field
   *   identifiers, the values are arrays for specifying the field settings. The
   *   structure of those arrays looks like this:
   *   - name: The human-readable name for the field.
   *   - description: A description of the field, if available.
   *   - indexed: Boolean indicating whether the field is indexed or not.
   *   - type: The type set for this field. One of the types returned by
   *     search_api_default_field_types().
   *   - real_type: (optional) If a custom data type was selected for this
   *     field, this type will be stored here, and "type" contain the fallback
   *     default data type.
   *   - boost: A boost value for terms found in this field during searches.
   *     Usually only relevant for fulltext fields.
   *   - entity_type (optional): If set, the type of this field is really an
   *     entity. The "type" key will then contain "integer", meaning that
   *     servers will ignore this and merely index the entity's ID. Components
   *     displaying this field, though, are advised to use the entity label
   *     instead of the ID.
   *   If $get_additional is TRUE, this array is encapsulated in another
   *   associative array, which contains the above array under the "fields" key,
   *   and a list of related entities (field keys mapped to names) under the
   *   "additional fields" key.
   */
  public function getFields($only_indexed = TRUE, $get_additional = FALSE) {
    $fields = empty($this->options['fields']) ? array() : $this->options['fields'];
    $wrapper = $this->entityWrapper();
    $additional = array();
    $entity_types = entity_get_info();

    // First we need all already added prefixes.
    $added = ($only_indexed || empty($this->options['additional fields'])) ? array() : $this->options['additional fields'];
    foreach (array_keys($fields) as $key) {
      $len = strlen($key) + 1;
      $pos = $len;
      // The third parameter ($offset) to strrpos has rather weird behaviour,
      // necessitating this rather awkward code. It will iterate over all
      // prefixes of each field, beginning with the longest, adding all of them
      // to $added until one is encountered that was already added (which means
      // all shorter ones will have already been added, too).
      while ($pos = strrpos($key, ':', $pos - $len)) {
        $prefix = substr($key, 0, $pos);
        if (isset($added[$prefix])) {
          break;
        }
        $added[$prefix] = $prefix;
      }
    }

    // Then we walk through all properties and look if they are already
    // contained in one of the arrays.
    // Since this uses an iterative instead of a recursive approach, it is a bit
    // complicated, with three arrays tracking the current depth.

    // A wrapper for a specific field name prefix, e.g. 'user:' mapped to the user wrapper
    $wrappers = array('' => $wrapper);
    // Display names for the prefixes
    $prefix_names = array('' => '');
      // The list nesting level for entities with a certain prefix
    $nesting_levels = array('' => 0);

    $types = search_api_default_field_types();
    $flat = array();
    while ($wrappers) {
      foreach ($wrappers as $prefix => $wrapper) {
        $prefix_name = $prefix_names[$prefix];
        // Deal with lists of entities.
        $nesting_level = $nesting_levels[$prefix];
        $type_prefix = str_repeat('list<', $nesting_level);
        $type_suffix = str_repeat('>', $nesting_level);
        if ($nesting_level) {
          $info = $wrapper->info();
          // The real nesting level of the wrapper, not the accumulated one.
          $level = search_api_list_nesting_level($info['type']);
          for ($i = 0; $i < $level; ++$i) {
            $wrapper = $wrapper[0];
          }
        }
        // Now look at all properties.
        foreach ($wrapper as $property => $value) {
          $info = $value->info();
          // We hide the complexity of multi-valued types from the user here.
          $type = search_api_extract_inner_type($info['type']);
          // Treat Entity API type "token" as our "string" type.
          // Also let text fields with limited options be of type "string" by default.
          if ($type == 'token' || ($type == 'text' && !empty($info['options list']))) {
            // Inner type is changed to "string".
            $type = 'string';
            // Set the field type accordingly.
            $info['type'] = search_api_nest_type('string', $info['type']);
          }
          $info['type'] = $type_prefix . $info['type'] . $type_suffix;
          $key = $prefix . $property;
          if ((isset($types[$type]) || isset($entity_types[$type])) && (!$only_indexed || !empty($fields[$key]))) {
            if (!empty($fields[$key])) {
              // This field is already known in the index configuration.
              $flat[$key] = $fields[$key] + array(
                'name' => $prefix_name . $info['label'],
                'description' => empty($info['description']) ? NULL : $info['description'],
                'boost' => '1.0',
                'indexed' => TRUE,
              );
              // Update the type and its nesting level for non-entity properties.
              if (!isset($entity_types[$type])) {
                $flat[$key]['type'] = search_api_nest_type(search_api_extract_inner_type($flat[$key]['type']), $info['type']);
                if (isset($flat[$key]['real_type'])) {
                  $real_type = search_api_extract_inner_type($flat[$key]['real_type']);
                  $flat[$key]['real_type'] = search_api_nest_type($real_type, $info['type']);
                }
              }
            }
            else {
              $flat[$key] = array(
                'name'    => $prefix_name . $info['label'],
                'description' => empty($info['description']) ? NULL : $info['description'],
                'type'    => $info['type'],
                'boost' => '1.0',
                'indexed' => FALSE,
              );
            }
            if (isset($entity_types[$type])) {
              $base_type = isset($entity_types[$type]['entity keys']['name']) ? 'string' : 'integer';
              $flat[$key]['type'] = search_api_nest_type($base_type, $info['type']);
              $flat[$key]['entity_type'] = $type;
            }
          }
          if (empty($types[$type])) {
            if (isset($added[$key])) {
              // Visit this entity/struct in a later iteration.
              $wrappers[$key . ':'] = $value;
              $prefix_names[$key . ':'] = $prefix_name . $info['label'] . ' » ';
              $nesting_levels[$key . ':'] = search_api_list_nesting_level($info['type']);
            }
            else {
              $name = $prefix_name . $info['label'];
              // Add machine names to discern fields with identical labels.
              if (isset($used_names[$name])) {
                if ($used_names[$name] !== FALSE) {
                  $additional[$used_names[$name]] .= ' [' . $used_names[$name] . ']';
                  $used_names[$name] = FALSE;
                }
                $name .= ' [' . $key . ']';
              }
              $additional[$key] = $name;
              $used_names[$name] = $key;
            }
          }
        }
        unset($wrappers[$prefix]);
      }
    }

    if (!$get_additional) {
      return $flat;
    }
    $options = array();
    $options['fields'] = $flat;
    $options['additional fields'] = $additional;
    return $options;
  }

  /**
   * Convenience method for getting all of this index's fulltext fields.
   *
   * @param boolean $only_indexed
   *   If set to TRUE, only the indexed fulltext fields will be returned.
   *
   * @return array
   *   An array containing all (or all indexed) fulltext fields defined for this
   *   index.
   */
  public function getFulltextFields($only_indexed = TRUE) {
    $i = $only_indexed ? 1 : 0;
    if (!isset($this->fulltext_fields[$i])) {
      $this->fulltext_fields[$i] = array();
      $fields = $only_indexed ? $this->options['fields'] : $this->getFields(FALSE);
      foreach ($fields as $key => $field) {
        if (search_api_is_text_type($field['type'])) {
          $this->fulltext_fields[$i][] = $key;
        }
      }
    }
    return $this->fulltext_fields[$i];
  }

  /**
   * Helper function for creating an entity metadata wrapper appropriate for
   * this index.
   *
   * @param $item
   *   Unless NULL, an item of this index's item type which should be wrapped.
   * @param $alter
   *   Whether to apply the index's active data alterations on the property
   *   information used. To also apply the data alteration to the wrapped item,
   *   execute SearchApiIndex::dataAlter() on it before calling this method.
   *
   * @return EntityMetadataWrapper
   *   A wrapper for the item type of this index, optionally loaded with the
   *   given data and having additional fields according to the data alterations
   *   of this index.
   */
  public function entityWrapper($item = NULL, $alter = TRUE) {
    $info['property info alter'] = $alter ? array($this, 'propertyInfoAlter') : '_search_api_wrapper_add_all_properties';
    $info['property defaults']['property info alter'] = '_search_api_wrapper_add_all_properties';
    return $this->datasource()->getMetadataWrapper($item, $info);
  }

  /**
   * Helper method to load items from the type lying on this index.
   *
   * @param array $ids
   *   The IDs of the items to load.
   *
   * @return array
   *   The requested items, as loaded by the data source.
   *
   * @see SearchApiDataSourceControllerInterface::loadItems()
   */
  public function loadItems(array $ids) {
    return $this->datasource()->loadItems($ids);
  }

  /**
   * Reset internal static caches.
   *
   * Should be used when things like fields or data alterations change to avoid
   * using stale data.
   */
  public function resetCaches() {
    $this->datasource = NULL;
    $this->server_object = NULL;
    $this->callbacks = NULL;
    $this->processors = NULL;
    $this->added_properties = NULL;
    $this->fulltext_fields = array();
  }

}
