<?php

namespace Drupal\search_api\Plugin\views\field;

use Drupal\Component\Render\MarkupInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\TypedData\ComplexDataInterface;
use Drupal\Core\TypedData\DataReferenceInterface;
use Drupal\Core\TypedData\ListInterface;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\Core\TypedData\TypedDataManagerInterface;
use Drupal\search_api\LoggerTrait;
use Drupal\search_api\Plugin\views\SearchApiHandlerTrait;
use Drupal\search_api\Processor\ConfigurablePropertyInterface;
use Drupal\search_api\Processor\ProcessorPropertyInterface;
use Drupal\search_api\SearchApiException;
use Drupal\search_api\Utility\FieldsHelperInterface;
use Drupal\search_api\Utility\Utility;
use Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface;
use Drupal\views\ResultRow;

/**
 * Provides a trait to use for Search API Views field handlers.
 *
 * Multi-valued field handling is taken from
 * \Drupal\views\Plugin\views\field\PrerenderList.
 *
 * Note: Some method parameters are documented as type array|\ArrayAccess. This
 * is just done to avoid the code sniffer complaining about the missing "array"
 * type hint (since it's impossible to add it, due to the Views parent plugin
 * classes not having that type hint, either).
 */
trait SearchApiFieldTrait {

  use LoggerTrait;
  use SearchApiHandlerTrait;

  /**
   * Contains the properties needed by this field handler.
   *
   * The array is keyed by datasource ID (which might be NULL) and property
   * path, the values are the combined property paths.
   *
   * @var string[][]
   */
  protected $retrievedProperties = [];

  /**
   * The combined property path of this field.
   *
   * @var string|null
   */
  protected $combinedPropertyPath;

  /**
   * The datasource ID of this field, if any.
   *
   * @var string|null
   */
  protected $datasourceId;

  /**
   * Contains overridden values to be returned on the next getValue() call.
   *
   * The values are keyed by the field given as $field in the call, so that it's
   * possible to return different values based on the field.
   *
   * @var array
   *
   * @see SearchApiFieldTrait::getValue()
   */
  protected $overriddenValues = [];

  /**
   * Index in the current row's field values that is currently displayed.
   *
   * @var int
   *
   * @see SearchApiFieldTrait::getEntity()
   */
  protected $valueIndex = 0;

  /**
   * The account to use for access checks for this search.
   *
   * @var \Drupal\Core\Session\AccountInterface|false|null
   *
   * @see \Drupal\search_api\Plugin\views\field\SearchApiFieldTrait::checkEntityAccess()
   */
  protected $accessAccount;

  /**
   * Associative array keyed by property paths for which to skip access checks.
   *
   * Values are all TRUE.
   *
   * @var bool[]
   */
  protected $skipAccessChecks = [];

  /**
   * Array of replacement property paths to use when getting field values.
   *
   * @var string[]
   *
   * @see \Drupal\search_api\Plugin\views\field\SearchApiFieldTrait::extractProcessorProperty()
   */
  protected $propertyReplacements = [];

  /**
   * The fields helper.
   *
   * @var \Drupal\search_api\Utility\FieldsHelperInterface|null
   */
  protected $fieldsHelper;

  /**
   * The typed data manager.
   *
   * @var \Drupal\Core\TypedData\TypedDataManagerInterface|null
   */
  protected $typedDataManager;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Retrieves the typed data manager.
   *
   * @return \Drupal\Core\TypedData\TypedDataManagerInterface
   *   The typed data manager.
   */
  public function getTypedDataManager() {
    return $this->typedDataManager ?: \Drupal::service('typed_data_manager');
  }

  /**
   * Sets the typed data manager.
   *
   * @param \Drupal\Core\TypedData\TypedDataManagerInterface $typed_data_manager
   *   The new typed data manager.
   *
   * @return $this
   */
  public function setTypedDataManager(TypedDataManagerInterface $typed_data_manager) {
    $this->typedDataManager = $typed_data_manager;
    return $this;
  }

  /**
   * Retrieves the entity type manager.
   *
   * @return \Drupal\Core\Entity\EntityTypeManagerInterface
   *   The entity type manager.
   */
  public function getEntityTypeManager() {
    return $this->entityTypeManager ?: \Drupal::entityTypeManager();
  }

  /**
   * Sets the entity type manager.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   *
   * @return $this
   */
  public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) {
    $this->entityTypeManager = $entity_type_manager;
    return $this;
  }

  /**
   * Retrieves the fields helper.
   *
   * @return \Drupal\search_api\Utility\FieldsHelperInterface
   *   The fields helper.
   */
  public function getFieldsHelper() {
    return $this->fieldsHelper ?: \Drupal::service('search_api.fields_helper');
  }

  /**
   * Sets the fields helper.
   *
   * @param \Drupal\search_api\Utility\FieldsHelperInterface $fields_helper
   *   The new fields helper.
   *
   * @return $this
   */
  public function setFieldsHelper(FieldsHelperInterface $fields_helper) {
    $this->fieldsHelper = $fields_helper;
    return $this;
  }

  /**
   * Determines whether this field can have multiple values.
   *
   * When this can't be reliably determined, the method defaults to TRUE.
   *
   * @return bool
   *   TRUE if this field can have multiple values (or if it couldn't be
   *   determined); FALSE otherwise.
   */
  public function isMultiple() {
    return $this instanceof MultiItemsFieldHandlerInterface;
  }

  /**
   * Defines the options used by this plugin.
   *
   * @return array
   *   Returns the options of this handler/plugin.
   *
   * @see \Drupal\views\Plugin\views\PluginBase::defineOptions()
   */
  public function defineOptions() {
    $options = parent::defineOptions();

    $options['link_to_item'] = ['default' => FALSE];
    $options['use_highlighting'] = ['default' => FALSE];

    if ($this->isMultiple()) {
      $options['multi_type'] = ['default' => 'separator'];
      $options['multi_separator'] = ['default' => ', '];
    }

    return $options;
  }

  /**
   * Provide a form to edit options for this plugin.
   *
   * @param array|\ArrayAccess $form
   *   The existing form structure, passed by reference.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The current form state.
   *
   * @see \Drupal\views\Plugin\views\ViewsPluginInterface::buildOptionsForm()
   */
  public function buildOptionsForm(&$form, FormStateInterface $form_state) {
    parent::buildOptionsForm($form, $form_state);

    $form['link_to_item'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Link this field to its item'),
      '#description' => $this->t('Display this field as a link to its original entity or item.'),
      '#default_value' => $this->options['link_to_item'],
    ];

    $form['use_highlighting'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Use highlighted field data'),
      '#description' => $this->t('Display field with matches of the search keywords highlighted, if available.'),
      '#default_value' => $this->options['use_highlighting'],
    ];

    if ($this->isMultiple()) {
      $form['multi_value_settings'] = [
        '#type' => 'details',
        '#title' => $this->t('Multiple values handling'),
        '#description' => $this->t('If this field contains multiple values for an item, these settings will determine how they are handled.'),
        '#weight' => 80,
      ];

      $form['multi_type'] = [
        '#type' => 'radios',
        '#title' => $this->t('Display type'),
        '#options' => [
          'ul' => $this->t('Unordered list'),
          'ol' => $this->t('Ordered list'),
          'separator' => $this->t('Simple separator'),
        ],
        '#default_value' => $this->options['multi_type'],
        '#fieldset' => 'multi_value_settings',
        '#weight' => 0,
      ];
      $form['multi_separator'] = [
        '#type' => 'textfield',
        '#title' => $this->t('Separator'),
        '#default_value' => $this->options['multi_separator'],
        '#states' => [
          'visible' => [
            ':input[name="options[multi_type]"]' => ['value' => 'separator'],
          ],
        ],
        '#fieldset' => 'multi_value_settings',
        '#weight' => 1,
      ];
    }
  }

  /**
   * Adds an ORDER BY clause to the query for click sort columns.
   *
   * @param string $order
   *   Either "ASC" or "DESC".
   *
   * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::clickSort()
   */
  public function clickSort($order) {
    $this->getQuery()->sort($this->definition['search_api field'], $order);
  }

  /**
   * Determines if this field is click sortable.
   *
   * This is the case if this Views field is linked to a certain Search API
   * field.
   *
   * @return bool
   *   TRUE if this field is available for click-sorting, FALSE otherwise.
   *
   * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::clickSortable()
   */
  public function clickSortable() {
    return !empty($this->definition['search_api field']);
  }

  /**
   * Add anything to the query that we might need to.
   *
   * @see \Drupal\views\Plugin\views\ViewsPluginInterface::query()
   */
  public function query() {
    $combined_property_path = $this->getCombinedPropertyPath();
    $field_id = NULL;
    if (!empty($this->definition['search_api field'])) {
      $field_id = $this->definition['search_api field'];
    }
    $this->addRetrievedProperty($combined_property_path, $field_id);
    if ($this->options['link_to_item']) {
      // @todo We don't actually know which object we need, might be from this
      //   property or any of its parents – depending where the closest entity
      //   ancestor is. To be 100% accurate, we'd have to somehow already
      //   determine the correct property here.
      $this->addRetrievedProperty("$combined_property_path:_object");
    }
  }

  /**
   * Adds a property to be retrieved.
   *
   * @param string $combined_property_path
   *   The combined property path of the property that should be retrieved.
   *   "_object" can be used as a property name to indicate the loaded object is
   *   required.
   * @param string|null $field_id
   *   (optional) The ID of the field corresponding to this property, if any.
   *
   * @return $this
   */
  protected function addRetrievedProperty($combined_property_path, $field_id = NULL) {
    if ($field_id) {
      $this->getQuery()->addRetrievedFieldValue($field_id);
    }

    list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path);
    $this->retrievedProperties[$datasource_id][$property_path] = $combined_property_path;
    return $this;
  }

  /**
   * Gets the entity matching the current row and relationship.
   *
   * @param \Drupal\views\ResultRow $values
   *   An object containing all retrieved values.
   *
   * @return \Drupal\Core\Entity\EntityInterface
   *   Returns the entity matching the values.
   *
   * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::getEntity()
   */
  public function getEntity(ResultRow $values) {
    $combined_property_path = $this->getCombinedPropertyPath();
    list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path);

    if ($values->search_api_datasource !== $datasource_id) {
      return NULL;
    }

    $value_index = $this->valueIndex;
    // Only try two levels. Otherwise, we might end up at an entirely different
    // entity, cause we go too far up.
    $levels = 2;
    while ($levels--) {
      if (!empty($values->_relationship_objects[$combined_property_path][$value_index])) {
        /** @var \Drupal\Core\TypedData\TypedDataInterface $object */
        $object = $values->_relationship_objects[$combined_property_path][$value_index];
        $value = $object->getValue();
        if ($value instanceof EntityInterface) {
          return $value;
        }
      }

      if (!$property_path) {
        break;
      }
      // For multi-valued fields, the parent's index is not the same as the
      // field value's index.
      if (!empty($values->_relationship_parent_indices[$combined_property_path][$value_index])) {
        $value_index = $values->_relationship_parent_indices[$combined_property_path][$value_index];
      }
      list($property_path) = Utility::splitPropertyPath($property_path);
      $combined_property_path = $this->createCombinedPropertyPath($datasource_id, $property_path);
    }

    return NULL;
  }

  /**
   * Retrieves the value that's supposed to be rendered.
   *
   * This API exists so that other modules can easily set the values of the
   * field without having the need to change the render method as well.
   *
   * Overridden here to provide an easy way to let this method return arbitrary
   * values, without actually touching the $values array.
   *
   * @param \Drupal\views\ResultRow $values
   *   An object containing all retrieved values.
   * @param string $field
   *   Optional name of the field where the value is stored.
   *
   * @return mixed
   *   The field value to use.
   *
   * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::getValue()
   */
  public function getValue(ResultRow $values, $field = NULL) {
    return $this->overriddenValues[$field] ?? parent::getValue($values, $field);
  }

  /**
   * Runs before any fields are rendered.
   *
   * This gives the handlers some time to set up before any handler has
   * been rendered.
   *
   * @param \Drupal\views\ResultRow[]|\ArrayAccess $values
   *   An array of all ResultRow objects returned from the query.
   *
   * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::preRender()
   */
  public function preRender(&$values) {
    // We deal with the properties one by one, always loading the necessary
    // values for any nested properties coming afterwards.
    foreach ($this->expandRequiredProperties() as $datasource_id => $properties) {
      $datasource_id = $datasource_id ?: NULL;
      foreach ($properties as $property_path => $info) {
        $combined_property_path = $info['combined_property_path'];
        $dependents = $info['dependents'];

        if ($combined_property_path === NULL) {
          $this->preLoadResultItems($values, $dependents);
          continue;
        }

        $property_values = $this->getValuesToExtract($values, $datasource_id, $property_path, $combined_property_path, $dependents);
        $this->extractPropertyValues($values, $combined_property_path, $property_values, $dependents);
        $this->checkHighlighting($values, $datasource_id, $property_path, $combined_property_path);
      }
    }
  }

  /**
   * Expands the properties to retrieve for this field.
   *
   * The properties are taken from this object's $retrievedFieldValues property,
   * with all their ancestors also added to the array, with the ancestor
   * properties always ordered before their descendants.
   *
   * This will ensure, when dealing with these properties sequentially, that
   * the parent object necessary to load the "child" property is always already
   * loaded.
   *
   * @return array[][]
   *   The properties to retrieve, keyed by their datasource ID and property
   *   path. The values are associative arrays with the following keys:
   *   - combined_property_path: The "combined property path" of the retrieved
   *     property.
   *   - dependents: An array containing the originally required properties that
   *     led to this property being required.
   */
  protected function expandRequiredProperties() {
    $required_properties = [];
    foreach ($this->retrievedProperties as $datasource_id => $property_paths) {
      if ($datasource_id === '') {
        $datasource_id = NULL;
      }
      try {
        $index_properties = $this->getIndex()->getPropertyDefinitions($datasource_id);
      }
      catch (SearchApiException $e) {
        $this->logException($e);
        $index_properties = [];
      }
      foreach ($property_paths as $property_path => $combined_property_path) {
        // In case the property is configurable, create a new, unique combined
        // property path for this field so adding multiple fields based on the
        // same property works correctly.
        if (($index_properties[$property_path] ?? NULL) instanceof ConfigurablePropertyInterface
            && !empty($this->definition['search_api field'])) {
          $new_path = $combined_property_path . '|' . $this->definition['search_api field'];
          $this->propertyReplacements[$combined_property_path] = $new_path;
          $combined_property_path = $new_path;
        }
        $paths_to_add = [NULL];
        $path_to_add = '';
        foreach (explode(':', $property_path) as $component) {
          $path_to_add .= ($path_to_add ? ':' : '') . $component;
          $paths_to_add[] = $path_to_add;
        }
        foreach ($paths_to_add as $path_to_add) {
          if (!isset($required_properties[$datasource_id][$path_to_add])) {
            $path = $this->createCombinedPropertyPath($datasource_id, $path_to_add);
            if (isset($this->propertyReplacements[$path])) {
              $path = $this->propertyReplacements[$path];
            }
            $required_properties[$datasource_id][$path_to_add] = [
              'combined_property_path' => $path,
              'dependents' => [],
            ];
          }
          $required_properties[$datasource_id][$path_to_add]['dependents'][] = $combined_property_path;
        }
      }
    }
    return $required_properties;
  }

  /**
   * Pre-loads the result objects, where necessary.
   *
   * @param \Drupal\views\ResultRow[] $values
   *   The Views result rows for which result objects should be loaded.
   * @param string[] $dependents
   *   The actually required properties (as combined property paths) that
   *   depend on the result objects.
   */
  protected function preLoadResultItems(array $values, array $dependents) {
    $to_load = [];
    foreach ($values as $i => $row) {
      // If the object is already set on the result row, we've got nothing to do
      // here.
      if (!empty($row->_object)) {
        continue;
      }
      // Same if the object was loaded on the result item already.
      $object = $row->_item->getOriginalObject(FALSE);
      if ($object) {
        $row->_object = $object;
        $row->_relationship_objects[NULL] = [$object];
        continue;
      }
      // We also don't need to load the object if all field values that depend
      // on it are already present on the result row.
      $required = FALSE;
      foreach ($dependents as $dependent) {
        if (!isset($row->$dependent)) {
          $required = TRUE;
          break;
        }
      }
      if (!$required) {
        continue;
      }

      $to_load[$row->search_api_id] = $i;
    }

    if (!$to_load) {
      return;
    }

    $items = $this->getIndex()->loadItemsMultiple(array_keys($to_load));
    foreach ($to_load as $item_id => $i) {
      if (!empty($items[$item_id])) {
        $values[$i]->_object = $items[$item_id];
        $values[$i]->_relationship_objects[NULL] = [$items[$item_id]];
      }
    }
  }

  /**
   * Determines and prepares the property values that need to be extracted.
   *
   * @param \Drupal\views\ResultRow[] $values
   *   The Views result rows from which property values should be extracted.
   * @param string|null $datasource_id
   *   The datasource ID of the property to extract (or NULL for datasource-
   *   independent properties).
   * @param string $property_path
   *   The property path of the property to extract.
   * @param string $combined_property_path
   *   The combined property path of the property to extract.
   * @param string[] $dependents
   *   The actually required properties (as combined property paths) that
   *   depend on this property.
   *
   * @return \Drupal\Core\TypedData\TypedDataInterface[][]
   *   The values of the property for each result row, keyed by result row
   *   index.
   */
  protected function getValuesToExtract(array $values, $datasource_id, $property_path, $combined_property_path, array $dependents) {
    // Determine the path of the parent property, and the property key to
    // take from it for this property.
    list($parent_path, $name) = Utility::splitPropertyPath($property_path);
    $combined_parent_path = $this->createCombinedPropertyPath($datasource_id, $parent_path);

    // For top-level properties, we need the definition to check whether its
    // a processor-generated property later.
    $property = NULL;
    if (!$parent_path) {
      $datasource_properties = $this->getIndex()
        ->getPropertyDefinitions($datasource_id);
      if (isset($datasource_properties[$name])) {
        $property = $datasource_properties[$name];
      }
    }

    // Now go through all rows and add the property to them, if necessary.
    // We then extract the actual values in a second pass in order to be
    // able to use multi-loading for any encountered entities.
    /** @var \Drupal\Core\TypedData\TypedDataInterface[][] $property_values */
    $property_values = [];
    $entities_to_load = [];
    foreach ($values as $i => $row) {
      // Bail for rows with the wrong datasource for this property, or for
      // which this field doesn't even apply (which will usually be the
      // same, though).
      if (($datasource_id && $datasource_id !== $row->search_api_datasource)
          || !$this->isActiveForRow($row)) {
        continue;
      }

      // Then, make sure we even need this property for the current row. (Will
      // not be the case if all required properties that depend on this property
      // were already set on the row previously.)
      $required = FALSE;
      foreach ($dependents as $dependent) {
        if (!isset($row->$dependent)) {
          $required = TRUE;
          break;
        }
      }
      if (!$required) {
        continue;
      }

      // Check whether there are parent objects present. Otherwise, nothing we
      // can do here.
      if (empty($row->_relationship_objects[$combined_parent_path])) {
        continue;
      }

      // If the property key is "_object", we only needed to load the parent
      // object(s), so we just copy those to the result row object and we're
      // done.
      if ($name === '_object') {
        // The $row->_object is special, since we also set it in
        // \Drupal\search_api\Plugin\views\query\SearchApiQuery::addResults()
        // (conditionally). To keep it consistent, we make it single-valued
        // here, too.
        if ($combined_property_path !== '_object') {
          $row->$combined_property_path = $row->_relationship_objects[$combined_parent_path];
        }
        continue;
      }

      if (empty($row->_relationship_objects[$combined_property_path])) {
        // Check whether this is a processor-generated property and use
        // special code to retrieve it in that case.
        if ($property instanceof ProcessorPropertyInterface) {
          // Determine whether this property is required.
          $is_required = in_array($combined_property_path, $dependents);
          $this->extractProcessorProperty($property, $row, $datasource_id, $property_path, $combined_property_path, $is_required);
          continue;
        }

        foreach ($row->_relationship_objects[$combined_parent_path] as $j => $parent) {
          // Follow references.
          while ($parent instanceof DataReferenceInterface) {
            $parent = $parent->getTarget();
          }

          // At this point we need the parent to be a complex item,
          // otherwise it can't have any children (and thus, our property
          // can't be present).
          if (!($parent instanceof ComplexDataInterface)) {
            continue;
          }

          try {
            // Retrieve the actual typed data for the property and add it to
            // our property values.
            $typed_data = $parent->get($name);
            $property_values[$i][$j] = $typed_data;
            // Remember any encountered entity references so we can
            // multi-load them.
            if ($typed_data instanceof DataReferenceInterface) {
              /** @var \Drupal\Core\TypedData\DataReferenceDefinitionInterface $definition */
              $definition = $typed_data->getDataDefinition();
              $definition = $definition->getTargetDefinition();
              if ($definition instanceof EntityDataDefinitionInterface) {
                $entity_type_id = $definition->getEntityTypeId();
                $entity_type = $this->getEntityTypeManager()
                  ->getDefinition($entity_type_id);
                if ($entity_type->isStaticallyCacheable()) {
                  $entity_id = $typed_data->getTargetIdentifier();
                  if ($entity_id) {
                    $entities_to_load[$entity_type_id][$entity_id] = $entity_id;
                  }
                }
              }
            }
          }
          catch (\InvalidArgumentException) {
            // This can easily happen, for example, when requesting a field
            // that only exists on a different bundle. Unfortunately, there
            // is no ComplexDataInterface::hasProperty() method, so we can
            // only catch and ignore the exception.
          }
        }
      }
    }

    // Multi-load all entities we encountered before (to get them into the
    // static cache).
    foreach ($entities_to_load as $entity_type_id => $ids) {
      $this->getEntityTypeManager()
        ->getStorage($entity_type_id)
        ->loadMultiple($ids);
    }

    return $property_values;
  }

  /**
   * Extracts a processor-based property from an item.
   *
   * @param \Drupal\search_api\Processor\ProcessorPropertyInterface $property
   *   The property definition.
   * @param \Drupal\views\ResultRow $row
   *   The Views result row.
   * @param string|null $datasource_id
   *   The datasource ID of the property to extract (or NULL for datasource-
   *   independent properties).
   * @param string $property_path
   *   The property path of the property to extract.
   * @param string $combined_property_path
   *   The combined property path of the property to set.
   * @param bool $is_required
   *   TRUE if the property is directly required, FALSE if it should only be
   *   extracted because some child/ancestor properties are required.
   */
  protected function extractProcessorProperty(ProcessorPropertyInterface $property, ResultRow $row, $datasource_id, $property_path, $combined_property_path, $is_required) {
    $index = $this->getIndex();
    $processor = $index->getProcessor($property->getProcessorId());
    if (!$processor) {
      return;
    }

    // We need to call the processor's addFieldValues() method in order to get
    // the field value. We do this using a clone of the search item so as to
    // preserve the original state of the item. We also use a dummy field
    // object – either a clone of a fitting indexed field (to get its
    // configuration), or a newly created one.
    $property_fields = $this->getFieldsHelper()
      ->filterForPropertyPath($index->getFields(), $datasource_id, $property_path);
    if ($property_fields) {
      if (!empty($this->definition['search_api field'])
          && !empty($property_fields[$this->definition['search_api field']])) {
        $field_id = $this->definition['search_api field'];
        $dummy_field = $property_fields[$field_id];
      }
      else {
        $dummy_field = reset($property_fields);
      }
      $dummy_field = clone $dummy_field;
    }
    else {
      $dummy_field = $this->getFieldsHelper()
        ->createFieldFromProperty($index, $property, $datasource_id, $property_path, 'tmp', 'string');
    }
    /** @var \Drupal\search_api\Item\ItemInterface $dummy_item */
    $dummy_item = clone $row->_item;
    $dummy_item->setFields([
      'tmp' => $dummy_field,
    ]);
    $dummy_item->setFieldsExtracted(TRUE);

    $processor->addFieldValues($dummy_item);

    $row->_relationship_objects[$combined_property_path] = [];
    $set_values = $is_required && !isset($row->{$combined_property_path});
    if ($set_values) {
      $row->$combined_property_path = [];
    }
    foreach ($dummy_field->getValues() as $value) {
      if (!$this->checkEntityAccess($value, $combined_property_path)) {
        continue;
      }
      if ($set_values) {
        $row->{$combined_property_path}[] = $value;
      }
      $typed_data = $this->getTypedDataManager()
        ->create($property, $value);
      $row->_relationship_objects[$combined_property_path][] = $typed_data;
      // Processor-generated properties always have just a single parent: the
      // result item itself. Therefore, the parent's index is always 0.
      $row->_relationship_parent_indices[$combined_property_path][] = 0;
    }
  }

  /**
   * Places extracted property values and objects into the result row.
   *
   * @param \Drupal\views\ResultRow[] $values
   *   The Views result rows from which property values should be extracted.
   * @param string $combined_property_path
   *   The combined property path of the property to extract.
   * @param \Drupal\Core\TypedData\TypedDataInterface[][] $property_values
   *   The values of the property for each result row, keyed by result row
   *   index.
   * @param string[] $dependents
   *   The actually required properties (as combined property paths) that
   *   depend on this property.
   */
  protected function extractPropertyValues(array $values, $combined_property_path, array $property_values, array $dependents) {
    // Now go through the rows a second time and actually add all objects
    // and (if necessary) properties.
    foreach ($values as $i => $row) {
      if (!empty($property_values[$i])) {
        // Add the typed data for the property to our relationship objects
        // for this property path.
        $row->_relationship_objects[$combined_property_path] = [];
        foreach ($property_values[$i] as $j => $typed_data) {
          // If the typed data is an entity, check whether the current
          // user can access it (and switch to the right translation, if
          // available).
          $value = $typed_data->getValue();
          if ($value instanceof EntityInterface) {
            if (!$this->checkEntityAccess($value, $combined_property_path)) {
              continue;
            }
            if ($value instanceof TranslatableInterface
                && $value->hasTranslation($row->search_api_language)) {
              // PhpStorm isn't able to keep both interfaces in mind at the same
              // time, so we need to use a third interface here that combines
              // both.
              /** @var \Drupal\Core\Entity\ContentEntityInterface $value */
              $typed_data = $value->getTranslation($row->search_api_language)
                ->getTypedData();
            }
          }

          // To treat list properties correctly regarding possible child
          // properties, add all the list items individually.
          if ($typed_data instanceof ListInterface) {
            foreach ($typed_data as $item) {
              $row->_relationship_objects[$combined_property_path][] = $item;
              $row->_relationship_parent_indices[$combined_property_path][] = $j;
            }
          }
          else {
            $row->_relationship_objects[$combined_property_path][] = $typed_data;
            $row->_relationship_parent_indices[$combined_property_path][] = $j;
          }
        }
      }

      // Determine whether we want to set field values for this property on this
      // row. This is the case if the property is one of the explicitly
      // retrieved properties and not yet set on the result row object. Also, if
      // we have no objects for this property, we needn't bother anyways, of
      // course.
      if (!in_array($combined_property_path, $dependents)
          || isset($row->$combined_property_path)
          || empty($row->_relationship_objects[$combined_property_path])) {
        continue;
      }

      $row->$combined_property_path = [];

      // Iterate over the typed data objects, extract their values and set
      // the relationship objects for the next iteration of the outer loop
      // over properties.
      foreach ($row->_relationship_objects[$combined_property_path] as $typed_data) {
        $row->{$combined_property_path}[] = $this->getFieldsHelper()
          ->extractFieldValues($typed_data);
      }

      // If we just set any field values on the result row, clean them up
      // by merging them together (currently it's an array of arrays, but
      // it should be just a flat array).
      if ($row->$combined_property_path) {
        $row->$combined_property_path = call_user_func_array('array_merge', $row->$combined_property_path);
      }
    }
  }

  /**
   * Replaces extracted property values with highlighted field values.
   *
   * @param \Drupal\views\ResultRow[] $values
   *   The Views result rows for which highlighted field values should be added
   *   where applicable and possible.
   * @param string|null $datasource_id
   *   The datasource ID of the property to extract (or NULL for datasource-
   *   independent properties).
   * @param string $property_path
   *   The property path of the property to extract.
   * @param string $combined_property_path
   *   The combined property path of the property for which to add data.
   */
  protected function checkHighlighting(array $values, $datasource_id, $property_path, $combined_property_path) {
    // If using highlighting data wasn't enabled, we can skip all of this
    // anyways.
    if (empty($this->options['use_highlighting'])) {
      return;
    }

    // Since (currently) only fields can be highlighted, not arbitrary
    // properties, we needn't even bother if there are no matching fields.
    $fields = $this->getFieldsHelper()
      ->filterForPropertyPath($this->getIndex()->getFields(), $datasource_id, $property_path);
    if (!$fields) {
      return;
    }

    foreach ($values as $row) {
      // We only want highlighting data if we even wanted (and, thus, extracted)
      // the property's values in the first place.
      if (!isset($row->$combined_property_path)) {
        continue;
      }
      $highlighted_data = $row->_item->getExtraData('highlighted_fields');
      if (!$highlighted_data) {
        continue;
      }
      $highlighted_data = array_intersect_key($highlighted_data, $fields);
      if ($highlighted_data) {
        // There might be multiple fields with highlight data here, in rare
        // cases, but it's unclear how to combine them, or choose one over the
        // other, anyways, so just take the first one.
        $values = reset($highlighted_data);
        $values = $this->combineHighlightedValues($row->$combined_property_path, $values);
        $row->$combined_property_path = $values;
      }
    }
  }

  /**
   * Combines raw field values with highlighted ones to get a complete set.
   *
   * If highlighted field values are set on the result item, not all values
   * might be included, but only the ones with matches. Since we still want to
   * show all values, of course, we need to combine the highlighted values with
   * the ones we extracted.
   *
   * @param array $extracted_values
   *   All values for a field.
   * @param array $highlighted_values
   *   A subset of field values that are highlighted.
   *
   * @return array
   *   An array of normal and highlighted field values, avoiding duplicates as
   *   well as possible.
   */
  protected function combineHighlightedValues(array $extracted_values, array $highlighted_values) {
    // Make sure the arrays have consecutive numeric indices. (Is always the
    // case for $extracted_values.)
    $highlighted_values = array_values($highlighted_values);

    // Pre-sanitize the highlighted values with a very permissive setting to
    // make sure the highlighting HTML won't be escaped later.
    foreach ($highlighted_values as $i => $value) {
      if (!($value instanceof MarkupInterface)) {
        $highlighted_values[$i] = $this->sanitizeValue($value, 'xss_admin');
      }
    }

    $extracted_count = count($extracted_values);
    $highlight_count = count($highlighted_values);
    // If there are (at least) as many highlighted values as normal ones, we are
    // done here.
    if ($highlight_count >= $extracted_count) {
      return $highlighted_values;
    }

    // We now compute a "normalized" representation for all (extracted and
    // highlighted) values to be able to find duplicates.
    $normalize = function ($value) {
      $value = (string) $value;
      $value = strip_tags($value);
      $value = html_entity_decode($value);
      return $value;
    };
    $normalized_extracted = array_map($normalize, $extracted_values);
    $normalized_highlighted = array_map($normalize, $highlighted_values);
    $normalized_extracted = array_diff($normalized_extracted, $normalized_highlighted);

    // Make sure that we have no more than $extracted_count values in total.
    while (count($normalized_extracted) + $highlight_count > $extracted_count) {
      array_pop($normalized_extracted);
    }

    // Now combine the two arrays, maintaining the original order by taking a
    // highlighted value only where the extracted value was removed (probably/
    // hopefully by the array_diff()).
    $values = [];
    for ($i = 0; $i < $extracted_count; ++$i) {
      if (isset($normalized_extracted[$i])) {
        $values[] = $extracted_values[$i];
      }
      else {
        $values[] = array_shift($highlighted_values);
      }
    }
    return $values;
  }

  /**
   * Determines whether this field is active for the given row.
   *
   * This is usually determined by the row's datasource.
   *
   * @param \Drupal\views\ResultRow $row
   *   The result row.
   *
   * @return bool
   *   TRUE if this field handler might produce output for the given row, FALSE
   *   otherwise.
   */
  protected function isActiveForRow(ResultRow $row) {
    $datasource_ids = [NULL, $row->search_api_datasource];
    return in_array($this->getDatasourceId(), $datasource_ids, TRUE);
  }

  /**
   * Checks whether the searching user has access to the given value.
   *
   * If the value is not an entity, this will always return TRUE.
   *
   * @param mixed $value
   *   The value to check.
   * @param string $property_path
   *   The property path of the value.
   *
   * @return bool
   *   TRUE if the value is not an entity, or the searching user has access to
   *   it; FALSE otherwise.
   */
  protected function checkEntityAccess($value, $property_path) {
    if (!($value instanceof EntityInterface)) {
      return TRUE;
    }
    if (!empty($this->skipAccessChecks[$property_path])) {
      return TRUE;
    }
    if (!isset($this->accessAccount)) {
      $this->accessAccount = $this->getQuery()->getAccessAccount() ?: FALSE;
    }
    return $value->access('view', $this->accessAccount ?: NULL);
  }

  /**
   * Retrieves the combined property path of this field.
   *
   * @return string
   *   The combined property path.
   */
  public function getCombinedPropertyPath() {
    if (!isset($this->combinedPropertyPath)) {
      // Add the property path of any relationships used to arrive at this
      // field.
      $path = $this->realField;
      $relationships = $this->view->relationship;
      $relationship = $this;
      // While doing this, also note which relationships are configured to skip
      // access checks.
      $skip_access = [];
      while (!empty($relationship->options['relationship'])) {
        if (empty($relationships[$relationship->options['relationship']])) {
          break;
        }
        $relationship = $relationships[$relationship->options['relationship']];
        $path = $relationship->realField . ':' . $path;

        foreach ($skip_access as $i => $temp_path) {
          $skip_access[$i] = $relationship->realField . ':' . $temp_path;
        }
        if (!empty($relationship->options['skip_access'])) {
          $skip_access[] = $relationship->realField;
        }
      }
      $this->combinedPropertyPath = $path;
      // Set the field alias to the combined property path so that Views' code
      // can find the raw values, if necessary.
      $this->field_alias = $path;
      // Set the property paths that should skip access checks.
      $this->skipAccessChecks = array_fill_keys($skip_access, TRUE);
    }
    return $this->combinedPropertyPath;
  }

  /**
   * Creates a combined property path.
   *
   * A combined property path is similar to a "combined ID" in that it contains
   * information about both the datasource and the property path on that
   * datasource.
   *
   * The difference is that a combined property path, as used in this class, can
   * be NULL (to reference the original result item).
   *
   * @param string|null $datasource_id
   *   The datasource ID, or NULL for a datasource-independent property.
   * @param string|null $property_path
   *   The property path from the result item to the specified property, or NULL
   *   to reference the result item.
   *
   * @return string|null
   *   The combined property path.
   */
  protected function createCombinedPropertyPath($datasource_id, $property_path) {
    if ($property_path === NULL) {
      return NULL;
    }
    return Utility::createCombinedId($datasource_id, $property_path);
  }

  /**
   * Retrieves the ID of the datasource to which this field belongs.
   *
   * @return string|null
   *   The datasource ID of this field, or NULL if it doesn't belong to a
   *   specific datasource.
   */
  public function getDatasourceId() {
    if (!isset($this->datasourceId)) {
      list($this->datasourceId) = Utility::splitCombinedId($this->getCombinedPropertyPath());
    }
    return $this->datasourceId;
  }

  /**
   * Renders a single item of a row.
   *
   * @param int $count
   *   The index of the item inside the row.
   * @param mixed $item
   *   The item for the field to render.
   *
   * @return string
   *   The rendered output.
   *
   * @see \Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface::render_item()
   */
  public function render_item($count, $item) {
    $this->overriddenValues[NULL] = $item['value'];
    $render = $this->render(new ResultRow());
    $this->overriddenValues = [];
    return $render;
  }

  /**
   * Gets an array of items for the field.
   *
   * Items should be associative arrays with, if possible, "value" as the actual
   * displayable value of the item, plus any items that might be found in the
   * "alter" options array for creating links, etc., such as "path", "fragment",
   * "query", etc. Additionally, items that might be turned into tokens should
   * also be in this array.
   *
   * @param \Drupal\views\ResultRow $values
   *   The result row object containing the values.
   *
   * @return array[]
   *   An array of items for the field, with each item being an array itself.
   *
   * @see \Drupal\views\Plugin\views\field\PrerenderList::getItems()
   */
  public function getItems(ResultRow $values) {
    $property_path = $this->getCombinedPropertyPath();
    if (!empty($this->propertyReplacements[$property_path])) {
      $property_path = $this->propertyReplacements[$property_path];
    }
    if (!empty($values->$property_path)) {
      // Although it's undocumented, the field handler base class assumes items
      // will always be arrays. See #2648012 for documenting this.
      $items = [];
      foreach ((array) $values->$property_path as $i => $value) {
        $item = [
          'value' => $value,
        ];

        if ($this->options['link_to_item']) {
          $item['make_link'] = TRUE;
          $item['url'] = $this->getItemUrl($values, $i);
        }

        $items[] = $item;
      }
      return $items;
    }
    return [];
  }

  /**
   * Renders all items in this field together.
   *
   * @param array|\ArrayAccess $items
   *   The items provided by getItems() for a single row.
   *
   * @return string
   *   The rendered items.
   *
   * @see \Drupal\views\Plugin\views\field\PrerenderList::renderItems()
   */
  public function renderItems($items) {
    if (!empty($items)) {
      if ($this->options['multi_type'] == 'separator') {
        $render = [
          '#type' => 'inline_template',
          '#template' => '{{ items|safe_join(separator) }}',
          '#context' => [
            'items' => $items,
            'separator' => $this->sanitizeValue($this->options['multi_separator'], 'xss_admin'),
          ],
        ];
      }
      else {
        $render = [
          '#theme' => 'item_list',
          '#items' => $items,
          '#title' => NULL,
          '#list_type' => $this->options['multi_type'],
        ];
      }
      return $this->getRenderer()->render($render);
    }
    return '';
  }

  /**
   * Sanitizes the value for output.
   *
   * @param mixed $value
   *   The value being rendered.
   * @param string|null $type
   *   (optional) The type of sanitization needed. If not provided,
   *   \Drupal\Component\Utility\Html::escape() is used.
   *
   * @return \Drupal\Component\Render\MarkupInterface
   *   Returns the safe value.
   *
   * @see \Drupal\views\Plugin\views\HandlerBase::sanitizeValue()
   */
  public function sanitizeValue($value, $type = NULL) {
    // Pass-through values that are already markup objects.
    if ($value instanceof MarkupInterface) {
      return $value;
    }
    return parent::sanitizeValue($value, $type);
  }

  /**
   * Retrieves an alter options array for linking the given value to its item.
   *
   * @param \Drupal\views\ResultRow $row
   *   The Views result row object.
   * @param int $i
   *   The index in this field's values for which the item link should be
   *   retrieved.
   *
   * @return \Drupal\Core\Url|null
   *   The URL for the specified item, or NULL if it couldn't be found.
   */
  protected function getItemUrl(ResultRow $row, $i) {
    $this->valueIndex = $i;
    if ($entity = $this->getEntity($row)) {
      if ($entity->hasLinkTemplate('canonical')) {
        return $entity->toUrl('canonical');
      }
    }

    if (!empty($row->_relationship_objects[NULL][0])) {
      try {
        return $this->getIndex()
          ->getDatasource($row->search_api_datasource)
          ->getItemUrl($row->_relationship_objects[NULL][0]);
      }
      catch (SearchApiException) {
      }
    }

    return NULL;
  }

  /**
   * Returns the Render API renderer.
   *
   * @return \Drupal\Core\Render\RendererInterface
   *   The renderer.
   *
   * @see \Drupal\views\Plugin\views\field\FieldPluginBase::getRenderer()
   */
  abstract protected function getRenderer();

}
