<?php

/**
 * Search service class using Solr server.
 */
class SearchApiSolrService extends SearchApiAbstractService {

  /**
   * The date format that Solr uses, in PHP date() syntax.
   */
  const SOLR_DATE_FORMAT = 'Y-m-d\TH:i:s\Z';

  /**
   * The connection class used by this service.
   *
   * Must implement SearchApiSolrConnectionInterface.
   *
   * @var string
   */
  protected $connection_class = 'SearchApiSolrConnection';

  /**
   * A connection to the Solr server.
   *
   * @var SearchApiSolrConnectionInterface
   */
  protected $solr;

  /**
   * An array of all recognized types.
   *
   * Maps the type names to the prefixes used for identifying them in the Solr
   * schema.
   *
   * @var array
   */
  protected static $type_prefixes = array(
    'text' => 'tm',
    'tokens' => 'tm',
    'string' => 's',
    'integer' => 'i',
    'decimal' => 'f',
    'date' => 'd',
    'duration' => 'i',
    'boolean' => 'b',
    'uri' => 's',
    'location' => 'loc',
    'geohash' => 'geo',
  );

  /**
   * Static cache for getFieldNames().
   *
   * @var array
   */
  protected $fieldNames = array();

  /**
   * Metadata describing fields on the Solr/Lucene index.
   *
   * @see SearchApiSolrService::getFields().
   *
   * @var array
   */
  protected $fields;

  /**
   * Saves whether a commit operation was already scheduled for this server.
   *
   * @var bool
   */
  protected $commitScheduled = FALSE;

  /**
   * Request handler to use for this search query.
   *
   * @var string
   */
  protected $request_handler = NULL;

  /**
   * Overrides SearchApiAbstractService::configurationForm().
   */
  public function configurationForm(array $form, array &$form_state) {
    if ($this->options) {
      // Editing this server
      $form['server_description'] = array(
        '#type' => 'item',
        '#title' => t('Solr server URI'),
        '#description' => $this->getServerLink(),
      );
    }

    $options = $this->options + array(
      'scheme' => 'http',
      'host' => 'localhost',
      'port' => '8983',
      'path' => '/solr',
      'http_user' => '',
      'http_pass' => '',
      'excerpt' => FALSE,
      'retrieve_data' => FALSE,
      'highlight_data' => FALSE,
      'http_method' => 'POST',
      'autocorrect_spell' => TRUE,
      'autocorrect_suggest_words' => TRUE,
    );

    $form['scheme'] = array(
      '#type' => 'select',
      '#title' => t('HTTP protocol'),
      '#description' => t('The HTTP protocol to use for sending queries.'),
      '#default_value' => $options['scheme'],
      '#options' => array(
        'http' => 'http',
        'https' => 'https',
      ),
    );

    $form['host'] = array(
      '#type' => 'textfield',
      '#title' => t('Solr host'),
      '#description' => t('The host name or IP of your Solr server, e.g. <code>localhost</code> or <code>www.example.com</code>.'),
      '#default_value' => $options['host'],
      '#required' => TRUE,
    );
    $form['port'] = array(
      '#type' => 'textfield',
      '#title' => t('Solr port'),
      '#description' => t('The Jetty example server is at port 8983, while Tomcat uses 8080 by default.'),
      '#default_value' => $options['port'],
      '#required' => TRUE,
    );
    $form['path'] = array(
      '#type' => 'textfield',
      '#title' => t('Solr path'),
      '#description' => t('The path that identifies the Solr instance to use on the server.'),
      '#default_value' => $options['path'],
    );

    $form['http'] = array(
      '#type' => 'fieldset',
      '#title' => t('Basic HTTP authentication'),
      '#description' => t('If your Solr server is protected by basic HTTP authentication, enter the login data here.'),
      '#collapsible' => TRUE,
      '#collapsed' => empty($options['http_user']),
    );
    $form['http']['http_user'] = array(
      '#type' => 'textfield',
      '#title' => t('Username'),
      '#default_value' => $options['http_user'],
    );
    $form['http']['http_pass'] = array(
      '#type' => 'password',
      '#title' => t('Password'),
      '#description' => t('If this field is left blank and the HTTP username is filled out, the current password will not be changed.'),
    );

    $form['advanced'] = array(
      '#type' => 'fieldset',
      '#title' => t('Advanced'),
      '#collapsible' => TRUE,
      '#collapsed' => TRUE,
    );
    $form['advanced']['excerpt'] = array(
      '#type' => 'checkbox',
      '#title' => t('Return an excerpt for all results'),
      '#description' => t("If search keywords are given, use Solr's capabilities to create a highlighted search excerpt for each result. " .
          'Whether the excerpts will actually be displayed depends on the settings of the search, though.'),
      '#default_value' => $options['excerpt'],
    );
    $form['advanced']['retrieve_data'] = array(
      '#type' => 'checkbox',
      '#title' => t('Retrieve result data from Solr'),
      '#description' => t('When checked, result data will be retrieved directly from the Solr server. ' .
          'This might make item loads unnecessary. Only indexed fields can be retrieved. ' .
          'Note also that the returned field data might not always be correct, due to preprocessing and caching issues.'),
      '#default_value' => $options['retrieve_data'],
    );
    $form['advanced']['highlight_data'] = array(
      '#type' => 'checkbox',
      '#title' => t('Highlight retrieved data'),
      '#description' => t('When retrieving result data from the Solr server, try to highlight the search terms in the returned fulltext fields.'),
      '#default_value' => $options['highlight_data'],
    );
    // Highlighting retrieved data only makes sense when we retrieve data.
    // (Actually, internally it doesn't really matter. However, from a user's
    // perspective, having to check both probably makes sense.)
    $form['advanced']['highlight_data']['#states']['invisible']
        [':input[name="options[form][advanced][retrieve_data]"]']['checked'] = FALSE;

    $form['advanced']['http_method'] = array(
      '#type' => 'select',
      '#title' => t('HTTP method'),
      '#description' => t('The HTTP method to use for sending queries. Usually, POST will work fine in all cases.'),
      '#default_value' => $options['http_method'],
      '#options' => array(
        'POST' => 'POST',
        'GET' => 'GET',
      ),
    );

    if (module_exists('search_api_autocomplete')) {
      $form['advanced']['autocomplete'] = array(
        '#type' => 'fieldset',
        '#title' => t('Autocomplete'),
        '#collapsible' => TRUE,
        '#collapsed' => TRUE,
      );
      $form['advanced']['autocomplete']['autocorrect_spell'] = array(
        '#type' => 'checkbox',
        '#title' => t('Use spellcheck for autocomplete suggestions'),
        '#description' => t('If activated, spellcheck suggestions ("Did you mean") will be included in the autocomplete suggestions. Since the used dictionary contains words from all indexes, this might lead to leaking of sensitive data, depending on your setup.'),
        '#default_value' => $options['autocorrect_spell'],
      );
      $form['advanced']['autocomplete']['autocorrect_suggest_words'] = array(
        '#type' => 'checkbox',
        '#title' => t('Suggest additional words'),
        '#description' => t('If activated and the user enters a complete word, Solr will suggest additional words the user wants to search, which are often found (not searched!) together. This has been known to lead to strange results in some configurations – if you see inappropriate additional-word suggestions, you might want to deactivate this option.'),
        '#default_value' => $options['autocorrect_suggest_words'],
      );
    }

    return $form;
  }

  /**
   * Overrides SearchApiAbstractService::configurationFormValidate().
   */
  public function configurationFormValidate(array $form, array &$values, array &$form_state) {
    if (isset($values['port']) && (!is_numeric($values['port']) || $values['port'] < 0 || $values['port'] > 65535)) {
      form_error($form['port'], t('The port has to be an integer between 0 and 65535.'));
    }
  }

  /**
   * Overrides SearchApiAbstractService::configurationFormSubmit().
   */
  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
    // Since the form is nested into another, we can't simply use #parents for
    // doing this array restructuring magic. (At least not without creating an
    // unnecessary dependency on internal implementation.)
    $values += $values['http'];
    $values += $values['advanced'];
    $values += !empty($values['autocomplete']) ? $values['autocomplete'] : array();
    unset($values['http'], $values['advanced'], $values['autocomplete']);

    // Highlighting retrieved data only makes sense when we retrieve data.
    $values['highlight_data'] &= $values['retrieve_data'];

    // For password fields, there is no default value, they're empty by default.
    // Therefore we ignore empty submissions if the user didn't change either.
    if ($values['http_pass'] === ''
        && isset($this->options['http_user'])
        && $values['http_user'] === $this->options['http_user']) {
      $values['http_pass'] = $this->options['http_pass'];
    }

    parent::configurationFormSubmit($form, $values, $form_state);
  }

  /**
   * Overrides SearchApiAbstractService::supportsFeature().
   */
  public function supportsFeature($feature) {
    $supported = drupal_map_assoc(array(
      'search_api_autocomplete',
      'search_api_facets',
      'search_api_facets_operator_or',
      'search_api_grouping',
      'search_api_mlt',
      'search_api_multi',
      'search_api_spellcheck',
      'search_api_data_type_location',
      'search_api_data_type_geohash',
    ));
    return isset($supported[$feature]);
  }

  /**
   * Overrides SearchApiAbstractService::viewSettings().
   */
  public function viewSettings() {
    $output = '';
    $options = $this->options;

    $output .= "<dl>\n  <dt>";
    $output .= t('Solr server URI');
    $output .= "</dt>\n  <dd>";
    $output .= $this->getServerLink();
    $output .= '</dd>';
    if ($options['http_user']) {
      $output .= "\n  <dt>";
      $output .= t('Basic HTTP authentication');
      $output .= "</dt>\n  <dd>";
      $output .= t('Username: @user', array('@user' => $options['http_user']));
      $output .= "</dd>\n  <dd>";
      $output .= t('Password: @pass', array('@pass' => str_repeat('*', strlen($options['http_pass']))));
      $output .= '</dd>';
    }
    $output .= "\n</dl>";

    return $output;
  }

  /**
   * Returns a link to the Solr server, if the necessary options are set.
   */
  public function getServerLink() {
    if (!$this->options) {
      return '';
    }
    $host = $this->options['host'];
    if ($host == 'localhost' && !empty($_SERVER['SERVER_NAME'])) {
      $host = $_SERVER['SERVER_NAME'];
    }
    $url = $this->options['scheme'] . '://' . $host . ':' . $this->options['port'] . $this->options['path'];
    return l($url, $url);
  }

  /**
   * Create a connection to the Solr server as configured in $this->options.
   */
  protected function connect() {
    if (!$this->solr) {
      if (!class_exists($this->connection_class)) {
        throw new SearchApiException(t('Invalid class @class set as Solr connection class.', array('@class' => $this->connection_class)));
      }
      $options = $this->options + array('server' => $this->server->machine_name);
      $this->solr = new $this->connection_class($options);
      if (!($this->solr instanceof SearchApiSolrConnectionInterface)) {
        $this->solr = NULL;
        throw new SearchApiException(t('Invalid class @class set as Solr connection class.', array('@class' => $this->connection_class)));
      }
    }
  }

  /**
   * Overrides SearchApiAbstractService::addIndex().
   */
  public function addIndex(SearchApiIndex $index) {
    if (module_exists('search_api_multi') && module_exists('search_api_views')) {
      views_invalidate_cache();
    }
  }

  /**
   * Overrides SearchApiAbstractService::fieldsUpdated().
   */
  public function fieldsUpdated(SearchApiIndex $index) {
    if (module_exists('search_api_multi') && module_exists('search_api_views')) {
      views_invalidate_cache();
    }
    return TRUE;
  }

  /**
   * Overrides SearchApiAbstractService::removeIndex().
   */
  public function removeIndex($index) {
    if (module_exists('search_api_multi') && module_exists('search_api_views')) {
      views_invalidate_cache();
    }
    $id = is_object($index) ? $index->machine_name : $index;
    // Only delete the index's data if the index isn't read-only.
    if (!is_object($index) || empty($index->read_only)) {
      try {
        $this->connect();
        $this->solr->deleteByQuery("index_id:" . $id);
      }
      catch (Exception $e) {
        watchdog_exception('search_api_solr', $e, "%type while deleting an index's data: !message in %function (line %line of %file).");
      }
    }
  }

  /**
   * Implements SearchApiServiceInterface::indexItems().
   */
  public function indexItems(SearchApiIndex $index, array $items) {
    $documents = array();
    $ret = array();
    $index_id = $index->machine_name;
    $fields = $this->getFieldNames($index);

    foreach ($items as $id => $item) {
      try {
        $doc = new SearchApiSolrDocument();
        $doc->setField('id', $this->createId($index_id, $id));
        $doc->setField('index_id', $index_id);
        $doc->setField('item_id', $id);

        foreach ($item as $key => $field) {
          if (!isset($fields[$key])) {
            throw new SearchApiException(t('Unknown field @field.', array('@field' => $key)));
          }
          $this->addIndexField($doc, $fields[$key], $field['value'], $field['type']);
        }

        $documents[] = $doc;
        $ret[] = $id;
      }
      catch (Exception $e) {
        watchdog_exception('search_api_solr', $e, "%type while indexing @type with ID @id: !message in %function (line %line of %file).", array('@type' => $index->item_type, '@id' => $id), WATCHDOG_WARNING);
      }
    }

    if (!$documents) {
      return array();
    }
    try {
      $this->connect();
      $this->solr->addDocuments($documents);
      if (!empty($index->options['index_directly'])) {
        $this->scheduleCommit();
      }
      return $ret;
    }
    catch (SearchApiException $e) {
      watchdog_exception('search_api_solr', $e, "%type while indexing: !message in %function (line %line of %file).");
    }
    return array();
  }

  /**
   * Creates an ID used as the unique identifier at the Solr server.
   *
   * This has to consist of both index and item ID.
   */
  protected function createId($index_id, $item_id) {
    return "$index_id-$item_id";
  }

  /**
   * Create a list of all indexed field names mapped to their Solr field names.
   *
   * The special fields "search_api_id", "search_api_relevance", and "id" are
   * also included. Any Solr fields that exist on search results are mapped back
   * to their local field names in the final result set.
   *
   * @see SearchApiSolrService::search()
   */
  public function getFieldNames(SearchApiIndex $index, $reset = FALSE) {
    if (!isset($this->fieldNames[$index->machine_name]) || $reset) {
      // This array maps "local property name" => "solr doc property name".
      $ret = array(
        'search_api_id' => 'ss_search_api_id',
        'search_api_relevance' => 'score',
        'search_api_item_id' => 'item_id',
      );

      // Add the names of any fields configured on the index.
      $fields = (isset($index->options['fields']) ? $index->options['fields'] : array());
      foreach ($fields as $key => $field) {
        // Generate a field name; this corresponds with naming conventions in
        // our schema.xml
        $type = $field['type'];

        // Use the real type of the field if the server supports this type.
        if (isset($field['real_type'])) {
          $custom_type = search_api_extract_inner_type($field['real_type']);
          if ($this->supportsFeature('search_api_data_type_' . $custom_type)) {
            $type = $field['real_type'];
          }
        }

        $inner_type = search_api_extract_inner_type($type);
        $pref = isset(self::$type_prefixes[$inner_type]) ? self::$type_prefixes[$inner_type] : '';
        if ($pref != 'tm') {
          $pref .= $type == $inner_type ? 's' : 'm';
        }
        $name = $pref . '_' . $key;

        $ret[$key] = $name;
      }

      // Let modules adjust the field mappings.
      drupal_alter('search_api_solr_field_mapping', $index, $ret);

      $this->fieldNames[$index->machine_name] = $ret;
    }

    return $this->fieldNames[$index->machine_name];
  }

  /**
   * Helper method for indexing.
   *
   * Adds $value with field name $key to the document $doc. The format of $value
   * is the same as specified in SearchApiServiceInterface::indexItems().
   */
  protected function addIndexField(SearchApiSolrDocument $doc, $key, $value, $type, $multi_valued = FALSE) {
    // Don't index empty values (i.e., when field is missing).
    if (!isset($value)) {
      return;
    }
    if (search_api_is_list_type($type)) {
      $type = substr($type, 5, -1);
      foreach ($value as $v) {
        $this->addIndexField($doc, $key, $v, $type, TRUE);
      }
      return;
    }
    switch ($type) {
      case 'tokens':
        foreach ($value as $v) {
          $doc->addField($key, $v['value']);
        }
        return;
      case 'boolean':
        $value = $value ? 'true' : 'false';
        break;
      case 'date':
        $value = is_numeric($value) ? (int) $value : strtotime($value);
        if ($value === FALSE) {
          return;
        }
        $value = format_date($value, 'custom', self::SOLR_DATE_FORMAT, 'UTC');
        break;
      case 'integer':
        $value = (int) $value;
        break;
      case 'decimal':
        $value = (float) $value;
        break;
    }
    if ($multi_valued) {
      $doc->addField($key, $value);
    }
    else {
      $doc->setField($key, $value);
    }
  }

  /**
   * Implements SearchApiServiceInterface::deleteItems().
   *
   * This method has a custom, Solr-specific extension:
   *
   * If $ids is a string other than "all", it is treated as a Solr query. All
   * items matching that Solr query are then deleted. If $index is additionally
   * specified, then only those items also lying on that index will be deleted.
   *
   * It is up to the caller to ensure $ids is a valid query when the method is
   * called in this fashion.
   */
  public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
    try {
      $this->connect();
      if ($index) {
        $index_id = $index->machine_name;
        if (is_array($ids)) {
          $solr_ids = array();
          foreach ($ids as $id) {
            $solr_ids[] = $this->createId($index_id, $id);
          }
          $this->solr->deleteByMultipleIds($solr_ids);
        }
        elseif ($ids == 'all') {
          $this->solr->deleteByQuery("index_id:" . $index_id);
        }
        else {
          $this->solr->deleteByQuery("index_id:" . $index_id . ' (' . $ids . ')');
        }
      }
      else {
        $q = $ids == 'all' ? '*:*' : $ids;
        $this->solr->deleteByQuery($q);
      }
      $this->scheduleCommit();
    }
    catch(SearchApiException $e) {
      watchdog_exception('search_api_solr', $e, '%type while deleting items from server @server: !message in %function (line %line of %file).', array('@server' => $this->server->name));
    }
  }

  /**
   * Implements SearchApiServiceInterface::search().
   */
  public function search(SearchApiQueryInterface $query) {
    $time_method_called = microtime(TRUE);
    // Reset request handler.
    $this->request_handler = NULL;
    // Get field information.
    $index = $query->getIndex();
    $fields = $this->getFieldNames($index);
    // Get Solr connection.
    $this->connect();
    $version = $this->solr->getSolrVersion();

    // Extract keys.
    $keys = $query->getKeys();
    if (is_array($keys)) {
      $keys = $this->flattenKeys($keys);
    }

    // Set searched fields.
    $options = $query->getOptions();
    $search_fields = $query->getFields();
    // Get the index fields to be able to retrieve boosts.
    $index_fields = $index->getFields();
    $qf = array();
    foreach ($search_fields as $f) {
      $boost = '';
      $boost = isset($index_fields[$f]['boost']) ? '^' . $index_fields[$f]['boost'] : '';
      $qf[] = $fields[$f] . $boost;
    }

    // Extract filters.
    $filter = $query->getFilter();
    $fq = $this->createFilterQueries($filter, $fields, $index->options['fields']);
    $fq[] = 'index_id:' . $index->machine_name;

    // Extract sort.
    $sort = array();
    foreach ($query->getSort() as $field => $order) {
      $f = $fields[$field];
      if (substr($f, 0, 3) == 'ss_') {
        $f = 'sort_' . substr($f, 3);
      }
      $order = strtolower($order);
      $sort[$field] = "$f $order";
    }

    // Get facet fields.
    $facets = $query->getOption('search_api_facets', array());
    $facet_params = $this->getFacetParams($facets, $fields, $fq);

    // Handle highlighting.
    $highlight_params = $this->getHighlightParams($query);

    // Handle More Like This query.
    $mlt = $query->getOption('search_api_mlt');
    if ($mlt) {
      $mlt_params['qt'] = 'mlt';
      // The fields to look for similarities in.
      $mlt_fl = array();
      foreach($mlt['fields'] as $f) {
        // Solr 4 has a bug which results in numeric fields not being supported
        // in MLT queries.
        // Date fields don't seem to be supported at all.
        if ($fields[$f][0] === 'd' || ($version == 4 && in_array($fields[$f][0], array('i', 'f')))) {
          continue;
        }
        $mlt_fl[] = $fields[$f];
        // For non-text fields, set minimum word length to 0.
        if (isset($index->options['fields'][$f]['type']) && !search_api_is_text_type($index->options['fields'][$f]['type'])) {
          $mlt_params['f.' . $fields[$f] . '.mlt.minwl'] = 0;
        }
      }
      $mlt_params['mlt.fl'] = implode(',', $mlt_fl);
      $id = $this->createId($index->machine_name, $mlt['id']);
      $id = call_user_func(array($this->connection_class, 'phrase'), $id);
      $keys = 'id:' . $id;
    }

    // Handle spatial filters.
    if ($spatials = $query->getOption('search_api_location')) {
      foreach ($spatials as $i => $spatial) {
        if (empty($spatial['field']) || empty($spatial['lat']) || empty($spatial['lon'])) {
          continue;
        }

        unset($radius);
        $field = $fields[$spatial['field']];
        $escaped_field = SearchApiSolrConnection::escapeFieldName($field);
        $point = ((float) $spatial['lat']) . ',' . ((float) $spatial['lon']);

        // Prepare the filter settings.
        if (isset($spatial['radius'])) {
          $radius = (float) $spatial['radius'];
        }
        $spatial_method = 'geofilt';
        if (isset($spatial['method']) && in_array($spatial['method'], array('geofilt', 'bbox'))) {
          $spatial_method = $spatial['method'];
        }

        // Change the fq facet ranges to the correct fq.
        foreach ($fq as $key => $value) {
          // If the fq consists only of a filter on this field, replace it with
          // a range.
          $preg_field = preg_quote($escaped_field, '/');
          if (preg_match('/^' . $preg_field . ':\["?(\*|\d+(?:\.\d+)?)"? TO "?(\*|\d+(?:\.\d+)?)"?\]$/', $value, $m)) {
            unset($fq[$key]);
            if (is_numeric($lower = trim($m[1], '"')) && $lower) {
              // Sadly, this doesn't work. We usually don't need lower bounds,
              // though (our facetting implementation doesn't create them, so it
              // should be fine to ignore this for now.
              //$fq[] = "-{!$spatial_method pt=$point sfield=$field d=$lower}";
            }
            if (is_numeric($upper = trim($m[2], '"'))) {
              // Make the radius tighter accordingly.
              $radius = isset($radius) ? min($radius, $upper) : $upper;
            }
          }
        }

        // If either a radius was given in the option, or a filter was
        // encountered, set a filter for the lowest value.
        if (isset($radius)) {
          $fq[] = "{!$spatial_method pt=$point sfield=$field d=$radius}";
        }

        // Change sort on the field, if set.
        if (isset($sort[$spatial['field']])) {
          $sort[$spatial['field']] = str_replace($field, "geodist($field,$point)", $sort[$spatial['field']]);
        }

        // Change the facet parameters for spatial fields to return distance
        // facets.
        if (!empty($facets)) {
          if (!empty($facet_params['facet.field'])) {
            $facet_params['facet.field'] = array_diff($facet_params['facet.field'], array($field));
          }
          foreach ($facets as $delta => $facet) {
            if ($facet['field'] != $spatial['field']) {
              continue;
            }
            $steps = $facet['limit'] > 0 ? $facet['limit'] : 5;
            $step = (isset($radius) ? $radius : 100) / $steps;
            for ($k = $steps - 1; $k > 0; --$k) {
              $distance = $step * $k;
              $key = "spatial-$delta-$distance";
              $facet_params['facet.query'][] = "{!$spatial_method pt=$point sfield=$field d=$distance key=$key}";
            }
            foreach (array('limit', 'mincount', 'missing') as $setting) {
              unset($facet_params["f.$field.facet.$setting"]);
            }
          }
        }
      }
    }

    // Handle field collapsing / grouping.
    $grouping = $query->getOption('search_api_grouping');
    if (!empty($grouping['use_grouping'])) {
      $group_params['group'] = 'true';
      // We always want the number of groups returned so that we get pagers done
      // right.
      $group_params['group.ngroups'] = 'true';
      if (!empty($grouping['truncate'])) {
        $group_params['group.truncate'] = 'true';
      }
      if (!empty($grouping['group_facet'])) {
        $group_params['group.facet'] = 'true';
      }
      foreach ($grouping['fields'] as $collapse_field) {
        $type = $index_fields[$collapse_field]['type'];
        // Only single-valued fields are supported.
        if ($version < 4) {
          // For Solr 3.x, only string and boolean fields are supported.
          if (search_api_is_list_type($type) || !search_api_is_text_type($type, array('string', 'boolean', 'uri'))) {
            $warnings[] = t('Grouping is not supported for field @field. ' .
                'Only single-valued fields of type "String", "Boolean" or "URI" are supported.',
                array('@field' => $index_fields[$collapse_field]['name']));
            continue;
          }
        }
        else {
          if (search_api_is_list_type($type) || search_api_is_text_type($type)) {
            $warnings[] = t('Grouping is not supported for field @field. ' .
                'Only single-valued fields not indexed as "Fulltext" are supported.',
                array('@field' => $index_fields[$collapse_field]['name']));
            continue;
          }
        }
        $group_params['group.field'][] = $fields[$collapse_field];
      }
      if (empty($group_params['group.field'])) {
        unset($group_params);
      }
      else {
        if (!empty($grouping['group_sort'])) {
          foreach ($grouping['group_sort'] as $group_sort_field => $order) {
            if (isset($fields[$group_sort_field])) {
              $f = $fields[$group_sort_field];
              if (substr($f, 0, 3) == 'ss_') {
                $f = 'sort_' . substr($f, 3);
              }
              $order = strtolower($order);
              $group_params['group.sort'][] = $f . ' ' . $order;
            }
          }
          if (!empty($group_params['group.sort'])) {
            $group_params['group.sort'] = implode(', ', $group_params['group.sort']);
          }
        }
        if (!empty($grouping['group_limit']) && ($grouping['group_limit'] != 1)) {
          $group_params['group.limit'] = $grouping['group_limit'];
        }
      }
    }

    // Set defaults.
    if (!$keys) {
      $keys = NULL;
    }

    // Collect parameters.
    $params = array(
      'fl' => 'item_id,score',
      'qf' => $qf,
      'fq' => $fq,
    );
    if (isset($options['offset'])) {
      $params['start'] = $options['offset'];
    }
    $params['rows'] = isset($options['limit']) ? $options['limit'] : 1000000;
    if ($sort) {
      $params['sort'] = implode(', ', $sort);
    }
    if (!empty($facet_params['facet.field'])) {
      $params += $facet_params;
    }
    if (!empty($highlight_params)) {
      $params += $highlight_params;
    }
    if (!empty($options['search_api_spellcheck'])) {
      $params['spellcheck'] = 'true';
    }
    if (!empty($mlt_params['mlt.fl'])) {
      $params += $mlt_params;
    }
    if (!empty($group_params)) {
      $params += $group_params;
    }
    if (!empty($this->options['retrieve_data'])) {
      $params['fl'] = '*,score';
    }
    // Retrieve http method from server options.
    $http_method = !empty($this->options['http_method']) ? $this->options['http_method'] : 'POST';

    $call_args = array(
      'query'       => &$keys,
      'params'      => &$params,
      'http_method' => &$http_method,
    );
    if ($this->request_handler) {
      $this->setRequestHandler($this->request_handler, $call_args);
    }

    try {
      // Send search request.
      $time_processing_done = microtime(TRUE);
      drupal_alter('search_api_solr_query', $call_args, $query);
      $this->preQuery($call_args, $query);

      $response = $this->solr->search($keys, $params, $http_method);
      $time_query_done = microtime(TRUE);

      // Extract results.
      $results = $this->extractResults($query, $response);

      // Add warnings, if present.
      if (!empty($warnings)) {
        $results['warnings'] = isset($results['warnings']) ? array_merge($warnings, $results['warnings']) : $warnings;
      }

      // Extract facets.
      if ($facets = $this->extractFacets($query, $response)) {
        $results['search_api_facets'] = $facets;
      }

      drupal_alter('search_api_solr_search_results', $results, $query, $response);
      $this->postQuery($results, $query, $response);

      // Compute performance.
      $time_end = microtime(TRUE);
      $results['performance'] = array(
        'complete' => $time_end - $time_method_called,
        'preprocessing' => $time_processing_done - $time_method_called,
        'execution' => $time_query_done - $time_processing_done,
        'postprocessing' => $time_end - $time_query_done,
      );

      return $results;
    }
    catch (SearchApiException $e) {
      throw new SearchApiException(t('An error occurred while trying to search with Solr: @msg.', array('@msg' => $e->getMessage())));
    }
  }

  /**
   * Extract results from a Solr response.
   *
   * @param object $response
   *   A HTTP response object.
   *
   * @return array
   *   An array with two keys:
   *   - result count: The number of total results.
   *   - results: An array of search results, as specified by
   *     SearchApiQueryInterface::execute().
   */
  protected function extractResults(SearchApiQueryInterface $query, $response) {
    $index = $query->getIndex();
    $fields = $this->getFieldNames($index);
    $field_options = $index->options['fields'];

    // Set up the results array.
    $results = array();
    $results['results'] = array();
    // Keep a copy of the response in the results so it's possible to extract
    // further useful information out of it, if necessary.
    $results['search_api_solr_response'] = $response;

    // In some rare cases (e.g., MLT query with nonexistent ID) the response
    // will be NULL.
    if (!isset($response->response) && !isset($response->grouped)) {
      $results['result count'] = 0;
      return $results;
    }

    // If field collapsing has been enabled for this query, we need to process
    // the results differently.
    $grouping = $query->getOption('search_api_grouping');
    if (!empty($grouping['use_grouping']) && !empty($response->grouped)) {
      $docs = array();
      $results['result count'] = 0;
      foreach ($grouping['fields'] as $field) {
        if (!empty($response->grouped->{$fields[$field]})) {
          $results['result count'] += $response->grouped->{$fields[$field]}->ngroups;
          foreach ($response->grouped->{$fields[$field]}->groups as $group) {
            foreach ($group->doclist->docs as $doc) {
              $docs[] = $doc;
            }
          }
        }
      }
    }
    else {
      $results['result count'] = $response->response->numFound;
      $docs = $response->response->docs;
    }

    // Add each search result to the results array.
    foreach ($docs as $doc) {
      // Blank result array.
      $result = array(
        'id' => NULL,
        'score' => NULL,
        'fields' => array(),
      );

      // Extract properties from the Solr document, translating from Solr to
      // Search API property names. This reverses the mapping in
      // SearchApiSolrService::getFieldNames().
      foreach ($fields as $search_api_property => $solr_property) {
        if (isset($doc->{$solr_property})) {
          $result['fields'][$search_api_property] = $doc->{$solr_property};
          // Date fields need some special treatment to become valid date values
          // (i.e., timestamps) again.
          if (isset($field_options[$search_api_property]['type'])
              && $field_options[$search_api_property]['type'] == 'date'
              && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $result['fields'][$search_api_property])) {
            $result['fields'][$search_api_property] = strtotime($result['fields'][$search_api_property]);
          }
        }
      }

      // We can find the item id and score in the special 'search_api_*'
      // properties. Mappings are provided for these properties in
      // SearchApiSolrService::getFieldNames().
      $result['id'] = $result['fields']['search_api_item_id'];
      $result['score'] = $result['fields']['search_api_relevance'];

      $solr_id = $this->createId($index->machine_name, $result['id']);
      $excerpt = $this->getExcerpt($response, $solr_id, $result['fields'], $fields);
      if ($excerpt) {
        $result['excerpt'] = $excerpt;
      }

      // Use the result's id as the array key. By default, 'id' is mapped to
      // 'item_id' in SearchApiSolrService::getFieldNames().
      if ($result['id']) {
        $results['results'][$result['id']] = $result;
      }
    }

    // Check for spellcheck suggestions.
    if (module_exists('search_api_spellcheck') && $query->getOption('search_api_spellcheck')) {
      $results['search_api_spellcheck'] = new SearchApiSpellcheckSolr($response);
    }

    return $results;
  }

  /**
   * Extract and format highlighting information for a specific item from a Solr response.
   *
   * Will also use highlighted fields to replace retrieved field data, if the
   * corresponding option is set.
   */
  protected function getExcerpt($response, $id, array &$fields, array $field_mapping) {
    if (!isset($response->highlighting->$id)) {
      return FALSE;
    }
    $output = '';

    if (!empty($this->options['excerpt']) && !empty($response->highlighting->$id->spell)) {
      foreach ($response->highlighting->$id->spell as $snippet) {
        $snippet = strip_tags($snippet);
        $snippet = preg_replace('/^.*>|<.*$/', '', $snippet);
        $snippet = $this->formatHighlighting($snippet);
        // The created fragments sometimes have leading or trailing punctuation.
        // We remove that here for all common cases, but take care not to remove
        // < or > (so HTML tags stay valid).
        $snippet = trim($snippet, "\00..\x2F:;=\x3F..\x40\x5B..\x60");
        $output .= $snippet . ' … ';
      }
    }
    if (!empty($this->options['highlight_data'])) {
      foreach ($field_mapping as $search_api_property => $solr_property) {
        if (substr($solr_property, 0, 3) == 'tm_' && !empty($response->highlighting->$id->$solr_property)) {
          // Contrary to above, we here want to preserve HTML, so we just
          // replace the [HIGHLIGHT] tags with the appropriate format.
          $fields[$search_api_property] = $this->formatHighlighting($response->highlighting->$id->$solr_property);
        }
      }
    }

    return $output;
  }

  /**
   * Changes highlighting tags from our custom, HTML-safe ones to HTML.
   *
   * @param string|array $snippet
   *   The snippet(s) to format.
   *
   * @return string|array
   *   The snippet(s), properly formatted as HTML.
   */
  protected function formatHighlighting($snippet) {
    return preg_replace('#\[(/?)HIGHLIGHT\]#', '<$1strong>', $snippet);
  }

  /**
   * Extract facets from a Solr response.
   *
   * @param object $response
   *   A response object from SolrPhpClient.
   *
   * @return array
   *   An array describing facets that apply to the current results.
   */
  protected function extractFacets(SearchApiQueryInterface $query, $response) {
    $facets = array();

    if (!isset($response->facet_counts)) {
      return $facets;
    }

    $index = $query->getIndex();
    $fields = $this->getFieldNames($index);

    $extract_facets = $query->getOption('search_api_facets', array());

    if (isset($response->facet_counts->facet_fields)) {
      $facet_fields = $response->facet_counts->facet_fields;

      foreach ($extract_facets as $delta => $info) {
        $field = $fields[$info['field']];
        if (!empty($facet_fields->$field)) {
          $min_count = $info['min_count'];
          $terms = $facet_fields->$field;
          if ($info['missing']) {
            // We have to correctly incorporate the "_empty_" term.
            // This will ensure that the term with the least results is dropped,
            // if the limit would be exceeded.
            if (isset($terms->_empty_)) {
              if ($terms->_empty_ < $min_count) {
                unset($terms->_empty_);
              }
              else {
                $terms = (array) $terms;
                arsort($terms);
                if ($info['limit'] > 0 && count($terms) > $info['limit']) {
                  array_pop($terms);
                }
              }
            }
          }
          elseif (isset($terms->_empty_)) {
            $terms = clone $terms;
            unset($terms->_empty_);
          }
          $type = isset($index->options['fields'][$info['field']]['type']) ? search_api_extract_inner_type($index->options['fields'][$info['field']]['type']) : 'string';
          foreach ($terms as $term => $count) {
            if ($count >= $min_count) {
              if ($term === '_empty_') {
                $term = '!';
              }
              elseif ($type == 'boolean') {
                if ($term == 'true') {
                  $term = '"1"';
                }
                elseif ($term == 'false') {
                  $term = '"0"';
                }
              }
              elseif ($type == 'date') {
                $term = $term ? '"' . strtotime($term) . '"' : NULL;
              }
              else {
                $term = "\"$term\"";
              }
              if ($term) {
                $facets[$delta][] = array(
                  'filter' => $term,
                  'count' => $count,
                );
              }
            }
          }
          if (empty($facets[$delta])) {
            unset($facets[$delta]);
          }
        }
      }

    }

    if (isset($response->facet_counts->facet_queries)) {
      if ($spatials = $query->getOption('search_api_location')) {
        $queries = array();
        foreach ($response->facet_counts->facet_queries as $key => $count) {
          if (!preg_match('/^spatial-(.*)-(\d+(?:\.\d+)?)$/', $key, $m)) {
            continue;
          }
          if (empty($extract_facets[$m[1]])) {
            continue;
          }
          $facet = $extract_facets[$m[1]];
          if ($count >= $facet['min_count']) {
            $facets[$m[1]][] = array(
              'filter' => "[* {$m[2]}]",
              'count' => $count,
            );
          }
        }
      }
    }

    return $facets;
  }

  /**
   * Flatten a keys array into a single search string.
   *
   * @param array $keys
   *   The keys array to flatten, formatted as specified by
   *   SearchApiQueryInterface::getKeys().
   *
   * @return string
   *   A Solr query string representing the same keys.
   */
  protected function flattenKeys(array $keys) {
    $k = array();
    $or = $keys['#conjunction'] == 'OR';
    $neg = !empty($keys['#negation']);
    foreach (element_children($keys) as $i) {
      $key = $keys[$i];
      if (!$key) {
        continue;
      }
      if (is_array($key)) {
        $subkeys = $this->flattenKeys($key);
        if ($subkeys) {
          $nested_expressions = TRUE;
          // If this is a negated OR expression, we can't just use nested keys
          // as-is, but have to put them into parantheses.
          if ($or && $neg) {
            $subkeys = "($subkeys)";
          }
          $k[] = $subkeys;
        }
      }
      else {
        $key = trim($key);
        $key = call_user_func(array($this->connection_class, 'phrase'), $key);
        $k[] = $key;
      }
    }
    if (!$k) {
      return '';
    }

    // Formatting the keys into a Solr query can be a bit complex. The following
    // code will produce filters that look like this:
    //
    // #conjunction | #negation | return value
    // ----------------------------------------------------------------
    // AND          | FALSE     | A B C
    // AND          | TRUE      | -(A B C)
    // OR           | FALSE     | ((A) OR (B) OR (C))
    // OR           | TRUE      | -A -B -C

    // If there was just a single, unnested key, we can ignore all this.
    if (count($k) == 1 && empty($nested_expressions)) {
      $k = reset($k);
      return $neg ? "*:* AND -$k" : $k;
    }

    if ($or) {
      if ($neg) {
        return '*:* AND -' . implode(' AND -', $k);
      }
      return '((' . implode(') OR (', $k) . '))';
    }
    $k = implode(' ', $k);
    return $neg ? "*:* AND -($k)" : $k;
  }

  /**
   * Transforms a query filter into a flat array of Solr filter queries, using
   * the field names in $fields.
   */
  protected function createFilterQueries(SearchApiQueryFilterInterface $filter, array $solr_fields, array $fields) {
    $or = $filter->getConjunction() == 'OR';
    $fq = array();
    foreach ($filter->getFilters() as $f) {
      if (is_array($f)) {
        if (!isset($fields[$f[0]])) {
          throw new SearchApiException(t('Filter term on unknown or unindexed field @field.', array('@field' => $f[0])));
        }
        if ($f[1] !== '') {
          $fq[] = $this->createFilterQuery($solr_fields[$f[0]], $f[1], $f[2], $fields[$f[0]]);
        }
      }
      else {
        $q = $this->createFilterQueries($f, $solr_fields, $fields);
        if ($filter->getConjunction() != $f->getConjunction()) {
          // $or == TRUE means the nested filter has conjunction AND, and vice versa
          $sep = $or ? ' ' : ' OR ';
          $fq[] = count($q) == 1 ? reset($q) : '((' . implode(')' . $sep . '(', $q) . '))';
        }
        else {
          $fq = array_merge($fq, $q);
        }
      }
    }
    return ($or && count($fq) > 1) ? array('((' . implode(') OR (', $fq) . '))') : $fq;
  }

  /**
   * Create a single search query string according to the given field, value
   * and operator.
   */
  protected function createFilterQuery($field, $value, $operator, $field_info) {
    $field = call_user_func(array($this->connection_class, 'escapeFieldName'), $field);
    if ($value === NULL) {
      return ($operator == '=' ? '*:* AND -' : '') . "$field:[* TO *]";
    }
    $value = trim($value);
    $value = $this->formatFilterValue($value, search_api_extract_inner_type($field_info['type']));
    switch ($operator) {
      case '<>':
        return "*:* AND -($field:$value)";
      case '<':
        return "$field:{* TO $value}";
      case '<=':
        return "$field:[* TO $value]";
      case '>=':
        return "$field:[$value TO *]";
      case '>':
        return "$field:{{$value} TO *}";

      default:
        return "$field:$value";
    }
  }

  /**
   * Format a value for filtering on a field of a specific type.
   */
  protected function formatFilterValue($value, $type) {
    switch ($type) {
      case 'boolean':
        $value = $value ? 'true' : 'false';
        break;
      case 'date':
        $value = is_numeric($value) ? (int) $value : strtotime($value);
        if ($value === FALSE) {
          return 0;
        }
        $value = format_date($value, 'custom', self::SOLR_DATE_FORMAT, 'UTC');
        break;
    }
    return call_user_func(array($this->connection_class, 'phrase'), $value);
  }

  /**
   * Helper method for creating the facet field parameters.
   */
  protected function getFacetParams(array $facets, array $fields, array &$fq = array()) {
    if (!$facets) {
      return array();
    }
    $facet_params['facet'] = 'true';
    $facet_params['facet.sort'] = 'count';
    $facet_params['facet.limit'] = 10;
    $facet_params['facet.mincount'] = 1;
    $facet_params['facet.missing'] = 'false';
    $taggedFields = array();
    foreach ($facets as $info) {
      if (empty($fields[$info['field']])) {
        continue;
      }
      // String fields have their own corresponding facet fields.
      $field = $fields[$info['field']];
      // Check for the "or" operator.
      if (isset($info['operator']) && $info['operator'] === 'or') {
        // Remember that filters for this field should be tagged.
        $escaped = call_user_func(array($this->connection_class, 'escapeFieldName'), $fields[$info['field']]);
        $taggedFields[$escaped] = "{!tag=$escaped}";
        // Add the facet field.
        $facet_params['facet.field'][] = "{!ex=$escaped}$field";
      }
      else {
        // Add the facet field.
        $facet_params['facet.field'][] = $field;
      }
      // Set limit, unless it's the default.
      if ($info['limit'] != 10) {
        $facet_params["f.$field.facet.limit"] = $info['limit'] ? $info['limit'] : -1;
      }
      // Set mincount, unless it's the default.
      if ($info['min_count'] != 1) {
        $facet_params["f.$field.facet.mincount"] = $info['min_count'];
      }
      // Set missing, if specified.
      if ($info['missing']) {
        $facet_params["f.$field.facet.missing"] = 'true';
      }
    }
    // Tag filters of fields with "OR" facets.
    foreach ($taggedFields as $field => $tag) {
      $regex = '#(?<![^( ])' . preg_quote($field, '#') . ':#';
      foreach ($fq as $i => $filter) {
        // Solr can't handle two tags on the same filter, so we don't add two.
        // Another option here would even be to remove the other tag, too,
        // since we can be pretty sure that this filter does not originate from
        // a facet – however, wrong results would still be possible, and this is
        // definitely an edge case, so don't bother.
        if (preg_match($regex, $filter) && substr($filter, 0, 6) != '{!tag=') {
          $fq[$i] = $tag . $filter;
        }
      }
    }

    return $facet_params;
  }

  /**
   * Helper method for creating the highlighting parameters.
   *
   * (The $query parameter currently isn't used and only here for the potential
   * sake of subclasses.)
   *
   * @param SearchApiQueryInterface|SearchApiMultiQueryInterface $query
   *   The query object, either for a normal Search API query or a multi-index
   *   query.
   *
   * @return array
   *   An array of parameters to be added to the Solr search request.
   */
  protected function getHighlightParams($query) {
    $highlight_params = array();

    if (!empty($this->options['excerpt']) || !empty($this->options['highlight_data'])) {
      $highlight_params['hl'] = 'true';
      $highlight_params['hl.fl'] = 'spell';
      $highlight_params['hl.simple.pre'] = '[HIGHLIGHT]';
      $highlight_params['hl.simple.post'] = '[/HIGHLIGHT]';
      $highlight_params['hl.snippets'] = 3;
      $highlight_params['hl.fragsize'] = 70;
      $highlight_params['hl.mergeContiguous'] = 'true';
    }

    if (!empty($this->options['highlight_data'])) {
      $highlight_params['hl.fl'] = 'tm_*';
      $highlight_params['hl.snippets'] = 1;
      $highlight_params['hl.fragsize'] = 0;
      if (!empty($this->options['excerpt'])) {
        // If we also generate a "normal" excerpt, set the settings for the
        // "spell" field (which we use to generate the excerpt) back to the
        // above values.
        $highlight_params['f.spell.hl.snippets'] = 3;
        $highlight_params['f.spell.hl.fragsize'] = 70;
        // It regrettably doesn't seem to be possible to set hl.fl to several
        // values, if one contains wild cards (i.e., "t_*,spell" wouldn't work).
        $highlight_params['hl.fl'] = '*';
      }
    }

    return $highlight_params;
  }

  /**
   * Sets the request handler.
   *
   * This should also make the needed adjustments to the request parameters.
   *
   * @param $handler
   *   Name of the handler to set.
   * @param array $call_args
   *   An associative array containing all three arguments to the
   *   SearchApiSolrConnectionInterface::search() call ("query", "params" and
   *   "method") as references.
   *
   * @return bool
   *   TRUE iff this method invocation handled the given handler. This allows
   *   subclasses to recognize whether the request handler was already set by
   *   this method.
   */
  protected function setRequestHandler($handler, array &$call_args) {
    if ($handler == 'pinkPony') {
      $call_args['params']['qt'] = $handler;
      return TRUE;
    }
    return FALSE;
  }

  /**
   * Empty method called before sending a search query to Solr.
   *
   * This allows subclasses to apply custom changes before the query is sent to
   * Solr. Works exactly like hook_search_api_solr_query_alter().
   *
   * @param array $call_args
   *   An associative array containing all three arguments to the
   *   SearchApiSolrConnectionInterface::search() call ("query", "params" and
   *   "method") as references.
   * @param SearchApiQueryInterface $query
   *   The SearchApiQueryInterface object representing the executed search query.
   */
  protected function preQuery(array &$call_args, SearchApiQueryInterface $query) {
  }

  /**
   * Empty method to allow subclasses to apply custom changes before search results are returned.
   *
   * Works exactly like hook_search_api_solr_search_results_alter().
   *
   * @param array $results
   *   The results array that will be returned for the search.
   * @param SearchApiQueryInterface $query
   *   The SearchApiQueryInterface object representing the executed search query.
   * @param object $response
   *   The response object returned by Solr.
   */
  protected function postQuery(array &$results, SearchApiQueryInterface $query, $response) {
  }

  //
  // Autocompletion feature
  //

  /**
   * Implements SearchApiAutocompleteInterface::getAutocompleteSuggestions().
   */
  // Largely copied from the apachesolr_autocomplete module.
  public function getAutocompleteSuggestions(SearchApiQueryInterface $query, SearchApiAutocompleteSearch $search, $incomplete_key, $user_input) {
    $suggestions = array();
    // Reset request handler
    $this->request_handler = NULL;
    // Turn inputs to lower case, otherwise we get case sensivity problems.
    $incomp = drupal_strtolower($incomplete_key);

    $index = $query->getIndex();
    $fields = $this->getFieldNames($index);
    $complete = $query->getOriginalKeys();

    // Extract keys
    $keys = $query->getKeys();
    if (is_array($keys)) {
      $keys_array = array();
      while ($keys) {
        reset($keys);
        if (!element_child(key($keys))) {
          array_shift($keys);
          continue;
        }
        $key = array_shift($keys);
        if (is_array($key)) {
          $keys = array_merge($keys, $key);
        }
        else {
          $keys_array[$key] = $key;
        }
      }
      $keys = $this->flattenKeys($query->getKeys());
    }
    else {
      $keys_array = drupal_map_assoc(preg_split('/[-\s():{}\[\]\\\\"]+/', $keys, -1, PREG_SPLIT_NO_EMPTY));
    }
    if (!$keys) {
      $keys = NULL;
    }

    // Set searched fields
    $options = $query->getOptions();
    $search_fields = $query->getFields();
    $qf = array();
    foreach ($search_fields as $f) {
      $qf[] = $fields[$f];
    }

    // Extract filters
    $fq = $this->createFilterQueries($query->getFilter(), $fields, $index->options['fields']);
    $fq[] = 'index_id:' . $index->machine_name;

    // Autocomplete magic
    $facet_fields = array();
    foreach ($search_fields as $f) {
      $facet_fields[] = $fields[$f];
    }

    $limit = $query->getOption('limit', 10);

    $params = array(
      'qf' => $qf,
      'fq' => $fq,
      'rows' => 0,
      'facet' => 'true',
      'facet.field' => $facet_fields,
      'facet.prefix' => $incomp,
      'facet.limit' => $limit * 5,
      'facet.mincount' => 1,
      'spellcheck' => (!isset($this->options['autocorrect_spell']) || $this->options['autocorrect_spell']) ? 'true' : 'false',
      'spellcheck.count' => 1,
    );
    // Retrieve http method from server options.
    $http_method = !empty($this->options['http_method']) ? $this->options['http_method'] : 'POST';

    $call_args = array(
      'query'       => &$keys,
      'params'      => &$params,
      'http_method' => &$http_method,
    );
    if ($this->request_handler) {
      $this->setRequestHandler($this->request_handler, $call_args);
    }
    $second_pass = !isset($this->options['autocorrect_suggest_words']) || $this->options['autocorrect_suggest_words'];
    for ($i = 0; $i < ($second_pass ? 2 : 1); ++$i) {
      try {
        // Send search request
        $this->connect();
        drupal_alter('search_api_solr_query', $call_args, $query);
        $this->preQuery($call_args, $query);
        $response = $this->solr->search($keys, $params, $http_method);

        if (!empty($response->spellcheck->suggestions)) {
          $replace = array();
          foreach ($response->spellcheck->suggestions as $word => $data) {
            $replace[$word] = $data->suggestion[0];
          }
          $corrected = str_ireplace(array_keys($replace), array_values($replace), $user_input);
          if ($corrected != $user_input) {
            array_unshift($suggestions, array(
              'prefix' => t('Did you mean') . ':',
              'user_input' => $corrected,
            ));
          }
        }

        $matches = array();
        if (isset($response->facet_counts->facet_fields)) {
          foreach ($response->facet_counts->facet_fields as $terms) {
            foreach ($terms as $term => $count) {
              if (isset($matches[$term])) {
                // If we just add the result counts, we can easily get over the
                // total number of results if terms appear in multiple fields.
                // Therefore, we just take the highest value from any field.
                $matches[$term] = max($matches[$term], $count);
              }
              else {
                $matches[$term] = $count;
              }
            }
          }

          if ($matches) {
            // Eliminate suggestions that are too short or already in the query.
            foreach ($matches as $term => $count) {
              if (strlen($term) < 3 || isset($keys_array[$term])) {
                unset($matches[$term]);
              }
            }

            // Don't suggest terms that are too frequent (by default in more
            // than 90% of results).
            $result_count = $response->response->numFound;
            $max_occurrences = $result_count * variable_get('search_api_solr_autocomplete_max_occurrences', 0.9);
            if (($max_occurrences >= 1 || $i > 0) && $max_occurrences < $result_count) {
              foreach ($matches as $match => $count) {
                if ($count > $max_occurrences) {
                  unset($matches[$match]);
                }
              }
            }

            // The $count in this array is actually a score. We want the
            // highest ones first.
            arsort($matches);

            // Shorten the array to the right ones.
            $additional_matches = array_slice($matches, $limit - count($suggestions), NULL, TRUE);
            $matches = array_slice($matches, 0, $limit, TRUE);

            // Build suggestions using returned facets
            $incomp_length = strlen($incomp);
            foreach ($matches as $term => $count) {
              if (drupal_strtolower(substr($term, 0, $incomp_length)) == $incomp) {
                $suggestions[] = array(
                  'suggestion_suffix' => substr($term, $incomp_length),
                  'term' => $term,
                  'results' => $count,
                );
              }
              else {
                $suggestions[] = array(
                  'suggestion_suffix' => ' ' . $term,
                  'term' => $term,
                  'results' => $count,
                );
              }
            }
          }
        }
      }
      catch (SearchApiException $e) {
        watchdog_exception('search_api_solr', $e, "%type during autocomplete Solr query: !message in %function (line %line of %file).", array(), WATCHDOG_WARNING);
      }

      if (count($suggestions) >= $limit) {
        break;
      }
      // Change parameters for second query.
      unset($params['facet.prefix']);
      $keys = trim ($keys . ' ' . $incomplete_key);
    }

    return $suggestions;
  }

  //
  // SearchApiMultiServiceInterface methods
  //

  /**
   * Implements SearchApiMultiServiceInterface::queryMultiple().
   */
  public function queryMultiple(array $options = array()) {
    return new SearchApiMultiQuery($this->server, $options);
  }

  /**
   * Implements SearchApiMultiServiceInterface::searchMultiple().
   */
  public function searchMultiple(SearchApiMultiQueryInterface $query) {
    $time_method_called = microtime(TRUE);
    // Get field information
    $solr_fields = array(
      'search_api_id' => 'ss_search_api_id',
      'search_api_relevance' => 'score',
      'search_api_multi_index' => 'index_id',
    );
    $fields = array(
      'search_api_multi_index' => array(
        'type' => 'string',
      ),
    );
    foreach ($query->getIndexes() as $index_id => $index) {
      if (empty($index->options['fields'])) {
        continue;
      }
      $prefix = $index_id . ':';
      foreach ($this->getFieldNames($index) as $field => $key) {
        if (!isset($solr_fields[$field])) {
          $solr_fields[$prefix . $field] = $key;
        }
      }
      foreach ($index->options['fields'] as $field => $info) {
        $fields[$prefix . $field] = $info;
      }
    }

    // Extract keys
    $keys = $query->getKeys();
    if (is_array($keys)) {
      $keys = $this->flattenKeys($keys);
    }

    // Set searched fields
    $search_fields = $query->getFields();
    $qf = array();
    foreach ($search_fields as $f) {
      $qf[] = $solr_fields[$f];
    }

    // Extract filters
    $filter = $query->getFilter();
    $fq = $this->createFilterQueries($filter, $solr_fields, $fields);

    // Restrict search to searched indexes.
    $index_filter = array();
    foreach ($query->getIndexes() as $index_id => $index) {
      $index_filter[] = 'index_id:' . call_user_func(array($this->connection_class, 'phrase'), $index_id);
    }
    $fq[] = implode(' OR ', $index_filter);

    // Extract sort
    $sort = array();
    foreach ($query->getSort() as $f => $order) {
      $f = $solr_fields[$f];
      if (substr($f, 0, 3) == 'ss_') {
        $f = 'sort_' . substr($f, 3);
      }
      $order = strtolower($order);
      $sort[] = "$f $order";
    }

    // Get facet fields
    $facets = $query->getOption('search_api_facets') ? $query->getOption('search_api_facets') : array();
    $facet_params = $this->getFacetParams($facets, $solr_fields, $fq);

    // Handle highlighting.
    $highlight_params = $this->getHighlightParams($query);

    // Set defaults
    if (!$keys) {
      $keys = NULL;
    }
    $options = $query->getOptions();

    // Collect parameters
    $params = array(
      'fl' => 'item_id,index_id,score',
      'qf' => $qf,
      'fq' => $fq,
    );
    if (isset($options['offset'])) {
      $params['start'] = $options['offset'];
    }
    if (isset($options['limit'])) {
      $params['rows'] = $options['limit'];
    }
    if ($sort) {
      $params['sort'] = implode(', ', $sort);
    }
    if (!empty($facet_params['facet.field'])) {
      $params += $facet_params;
    }
    if (!empty($highlight_params)) {
      $params += $highlight_params;
    }

    // Retrieve http method from server options.
    $http_method = !empty($this->options['http_method']) ? $this->options['http_method'] : 'POST';

    // Send search request
    $time_processing_done = microtime(TRUE);
    $this->connect();

    $call_args = array(
      'query'       => &$keys,
      'params'      => &$params,
      'http_method' => &$http_method,
    );
    drupal_alter('search_api_solr_multi_query', $call_args, $query);

    $response = $this->solr->search($keys, $params, $http_method);
    $time_query_done = microtime(TRUE);

    // Extract results
    $results = array();
    $results['result count'] = $response->response->numFound;
    $results['results'] = array();
    $tmp = array();
    foreach ($response->response->docs as $id => $doc) {
      $result = array(
        'id' => $doc->item_id,
        'index_id' => $doc->index_id,
        'score' => $doc->score,
      );
      $solr_id = $this->createId($doc->index_id, $result['id']);
      $excerpt = $this->getExcerpt($response, $solr_id, $tmp, array());
      if ($excerpt) {
        $result['excerpt'] = $excerpt;
      }
      $results['results'][$id] = $result;
    }

    // Extract facets
    if (isset($response->facet_counts->facet_fields)) {
      $results['search_api_facets'] = array();
      $facet_fields = $response->facet_counts->facet_fields;
      foreach ($facets as $delta => $info) {
        $field = $solr_fields[$info['field']];
        if (!empty($facet_fields->$field)) {
          $min_count = $info['min_count'];
          $terms = $facet_fields->$field;
          if ($info['missing']) {
            // We have to correctly incorporate the "_empty_" term.
            // This will ensure that the term with the least results is dropped,
            // if the limit would be exceeded.
            if (isset($terms->_empty_)) {
              if ($terms->_empty_ < $min_count) {
                unset($terms->_empty_);
              }
              else {
                $terms = (array) $terms;
                arsort($terms);
                if ($info['limit'] > 0 && count($terms) > $info['limit']) {
                  array_pop($terms);
                }
              }
            }
          }
          elseif (isset($terms->_empty_)) {
            $terms = clone $terms;
            unset($terms->_empty_);
          }
          $type = isset($index->options['fields'][$info['field']]['type']) ? search_api_extract_inner_type($index->options['fields'][$info['field']]['type']) : 'string';
          foreach ($terms as $term => $count) {
            if ($count >= $min_count) {
              if ($term === '_empty_') {
                $term = '!';
              }
              elseif ($type == 'boolean') {
                if ($term == 'true') {
                  $term = '"1"';
                }
                elseif ($term == 'false') {
                  $term = '"0"';
                }
              }
              elseif ($type == 'date') {
                $term = $term ? '"' . strtotime($term) . '"' : NULL;
              }
              else {
                $term = "\"$term\"";
              }
              if ($term) {
                $results['search_api_facets'][$delta][] = array(
                  'filter' => $term,
                  'count' => $count,
                );
              }
            }
          }
          if (empty($results['search_api_facets'][$delta])) {
            unset($results['search_api_facets'][$delta]);
          }
        }
      }
    }

    // Compute performance
    $time_end = microtime(TRUE);
    $results['performance'] = array(
      'complete' => $time_end - $time_method_called,
      'preprocessing' => $time_processing_done - $time_method_called,
      'execution' => $time_query_done - $time_processing_done,
      'postprocessing' => $time_end - $time_query_done,
    );

    return $results;
  }

  //
  // Additional methods that might be used when knowing the service class.
  //

  /**
   * Ping the Solr server to tell whether it can be accessed.
   *
   * Uses the admin/ping request handler.
   */
  public function ping() {
    $this->connect();
    return $this->solr->ping();
  }

  /**
   * Sends a commit command to the Solr server.
   */
  public function commit() {
    try {
      $this->connect();
      return $this->solr->commit(FALSE);
    }
    catch (SearchApiException $e) {
      watchdog_exception('search_api_solr', $e,
          '%type while trying to commit on server @server: !message in %function (line %line of %file).',
          array('@server' => $this->server->machine_name), WATCHDOG_WARNING);
    }
  }

  /**
   * Schedules a commit operation for this server.
   *
   * The commit will be sent at the end of the current page request. Multiple
   * calls to this method will still only result in one commit operation.
   */
  public function scheduleCommit() {
    if (!$this->commitScheduled) {
      $this->commitScheduled = TRUE;
      drupal_register_shutdown_function(array($this, 'commit'));
    }
  }

  /**
   * Gets the Solr connection class used by this service.
   *
   * @return string
   *   The name of a class which implements SearchApiSolrConnectionInterface.
   */
  public function getConnectionClass() {
    return $this->connection_class;
  }

  /**
   * Sets the Solr connection class used by this service.
   *
   * @param string $class
   *   The name of a class which implements SearchApiSolrConnectionInterface.
   */
  public function setConnectionClass($class) {
    $this->connection_class = $class;
    $this->solr = NULL;
  }

  /**
   * Gets the currently used Solr connection object.
   *
   * @return SearchApiSolrConnectionInterface
   *   The solr connection object used by this server.
   */
  public function getSolrConnection() {
    $this->connect();
    return $this->solr;
  }

  /**
   * Get metadata about fields in the Solr/Lucene index.
   *
   * @param int $num_terms
   *   Number of 'top terms' to return.
   *
   * @return array
   *   An array of SearchApiSolrField objects.
   *
   * @see SearchApiSolrConnectionInterface::getFields()
   */
  public function getFields($num_terms = 0) {
    $this->connect();
    return $this->solr->getFields($num_terms);
  }

  /**
   * Retrieves a config file or file list from the Solr server.
   *
   * Uses the admin/file request handler.
   *
   * @param string|null $file
   *   (optional) The name of the file to retrieve. If the file is a directory,
   *   the directory contents are instead listed and returned. NULL represents
   *   the root config directory.
   *
   * @return object
   *  A HTTP response object containing either the file contents or a file list.
   */
  public function getFile($file = NULL) {
    $this->connect();

    $file_servlet_name = constant($this->connection_class . '::FILE_SERVLET');

    $params['contentType'] = 'text/xml;charset=utf-8';
    if ($file) {
      $params['file'] = $file;
    }
    return $this->solr->makeServletRequest($file_servlet_name, $params);
  }

}
