<?php

namespace Drupal\jsonapi\Context;

use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface;
use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
use Drupal\Core\TypedData\ComplexDataDefinitionInterface;
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
use Drupal\Core\TypedData\DataReferenceTargetDefinition;
use Drupal\jsonapi\ResourceType\ResourceType;
use Drupal\jsonapi\ResourceType\ResourceTypeRelationship;
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
use Drupal\Core\Session\AccountInterface;

/**
 * A service that evaluates external path expressions against Drupal fields.
 *
 * This class performs 3 essential functions, path resolution, path validation
 * and path expansion.
 *
 * Path resolution:
 * Path resolution refers to the ability to map a set of external field names to
 * their internal counterparts. This is necessary because a resource type can
 * provide aliases for its field names. For example, the resource type
 * "node--article" might "alias" the internal field name "uid" to the external
 * field name "author". This permits an API consumer to request
 * "/jsonapi/node/article?include=author" for a better developer experience.
 *
 * Path validation:
 * Path validation refers to the ability to ensure that a requested path
 * corresponds to a valid set of internal fields. For example, if an API
 * consumer may send a GET request to
 * "/jsonapi/node/article?sort=author.field_first_name". The field resolver
 * ensures that "uid" (which would have been resolved from "author") exists on
 * article nodes and that "field_first_name" exists on user entities. However,
 * in the case of an "include" path, the field resolver would raise a client
 * error because "field_first_name" is not an entity reference field, meaning it
 * does not identify any related resources that can be included in a compound
 * document.
 *
 * Path expansion:
 * Path expansion refers to the ability to expand a path to an entity query
 * compatible field expression. For example, a request URL might have a query
 * string like "?filter[field_tags.name]=aviation", before constructing the
 * appropriate entity query, the entity query system needs the path expression
 * to be "expanded" into "field_tags.entity.name". In some rare cases, the
 * entity query system needs this to be expanded to
 * "field_tags.entity:taxonomy_term.name"; the field resolver simply does this
 * by default for every path.
 *
 * *Note:* path expansion is *not* performed for "include" paths.
 *
 * @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
 *   may change at any time and could break any dependencies on it.
 *
 * @see https://www.drupal.org/project/drupal/issues/3032787
 * @see jsonapi.api.php
 */
class FieldResolver {

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

  /**
   * The field manager.
   *
   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
   */
  protected $fieldManager;

  /**
   * The entity type bundle information service.
   *
   * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
   */
  protected $entityTypeBundleInfo;

  /**
   * The JSON:API resource type repository service.
   *
   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
   */
  protected $resourceTypeRepository;

  /**
   * The module handler.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * The current user account.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $currentUser;

  /**
   * Creates a FieldResolver instance.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
   *   The field manager.
   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
   *   The bundle info service.
   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
   *   The resource type repository.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   * @param \Drupal\Core\Session\AccountInterface $current_user
   *   The current user account.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, ResourceTypeRepositoryInterface $resource_type_repository, ModuleHandlerInterface $module_handler, AccountInterface $current_user) {
    $this->currentUser = $current_user;
    $this->entityTypeManager = $entity_type_manager;
    $this->fieldManager = $field_manager;
    $this->entityTypeBundleInfo = $entity_type_bundle_info;
    $this->resourceTypeRepository = $resource_type_repository;
    $this->moduleHandler = $module_handler;
  }

  /**
   * Validates and resolves an include path into its internal possibilities.
   *
   * Each resource type may define its own external names for its internal
   * field names. As a result, a single external include path may target
   * multiple internal paths.
   *
   * This can happen when an entity reference field has different allowed entity
   * types *per bundle* (as is possible with comment entities) or when
   * different resource types share an external field name but resolve to
   * different internal fields names.
   *
   * Example 1:
   * An installation may have three comment types for three different entity
   * types, two of which have a file field and one of which does not. In that
   * case, a path like "field_comments.entity_id.media" might be resolved to
   * both "field_comments.entity_id.field_audio" and
   * "field_comments.entity_id.field_image".
   *
   * Example 2:
   * A path of "field_author_profile.account" might resolve to
   * "field_author_profile.uid" and "field_author_profile.field_user" if
   * "field_author_profile" can relate to two different JSON:API resource types
   * (like `node--profile` and `node--migrated_profile`) which have the external
   * field name "account" aliased to different internal field names.
   *
   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
   *   The resource type for which the path should be validated.
   * @param string[] $path_parts
   *   The include path as an array of strings. For example, the include query
   *   parameter string of "field_tags.uid" should be given
   *   as "['field_tags', 'uid']".
   * @param int $depth
   *   (internal) Used to track recursion depth in order to generate better
   *   exception messages.
   *
   * @return string[]
   *   The resolved internal include paths.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
   *   Thrown if the path contains invalid specifiers.
   */
  public static function resolveInternalIncludePath(ResourceType $resource_type, array $path_parts, $depth = 0) {
    $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:include']);
    if (empty($path_parts[0])) {
      throw new CacheableBadRequestHttpException($cacheability, 'Empty include path.');
    }
    $public_field_name = $path_parts[0];
    $internal_field_name = $resource_type->getInternalName($public_field_name);
    $relatable_resource_types = $resource_type->getRelatableResourceTypesByField($public_field_name);
    if (empty($relatable_resource_types)) {
      $message = "`$public_field_name` is not a valid relationship field name.";
      if (!empty(($possible = implode(', ', array_keys($resource_type->getRelatableResourceTypes()))))) {
        $message .= " Possible values: $possible.";
      }
      throw new CacheableBadRequestHttpException($cacheability, $message);
    }
    $remaining_parts = array_slice($path_parts, 1);
    if (empty($remaining_parts)) {
      return [[$internal_field_name]];
    }
    $exceptions = [];
    $resolved = [];
    foreach ($relatable_resource_types as $relatable_resource_type) {
      try {
        // Each resource type may resolve the path differently and may return
        // multiple possible resolutions.
        $resolved = array_merge($resolved, static::resolveInternalIncludePath($relatable_resource_type, $remaining_parts, $depth + 1));
      }
      catch (CacheableBadRequestHttpException $e) {
        $exceptions[] = $e;
      }
    }
    if (!empty($exceptions) && count($exceptions) === count($relatable_resource_types)) {
      $previous_messages = implode(' ', array_unique(array_map(function (CacheableBadRequestHttpException $e) {
        return $e->getMessage();
      }, $exceptions)));
      // Only add the full include path on the first level of recursion so that
      // the invalid path phrase isn't repeated at every level.
      throw new CacheableBadRequestHttpException($cacheability, $depth === 0
        ? sprintf("`%s` is not a valid include path. $previous_messages", implode('.', $path_parts))
        : $previous_messages
      );
    }
    // Remove duplicates by converting to strings and then using array_unique().
    $resolved_as_strings = array_map(function ($possibility) {
      return implode('.', $possibility);
    }, $resolved);
    $resolved_as_strings = array_unique($resolved_as_strings);

    // The resolved internal paths do not include the current field name because
    // resolution happens in a recursive process. Convert back from strings.
    return array_map(function ($possibility) use ($internal_field_name) {
      return array_merge([$internal_field_name], explode('.', $possibility));
    }, $resolved_as_strings);
  }

  /**
   * Resolves external field expressions into entity query compatible paths.
   *
   * It is often required to reference data which may exist across a
   * relationship. For example, you may want to sort a list of articles by
   * a field on the article author's representative entity. Or you may wish
   * to filter a list of content by the name of referenced taxonomy terms.
   *
   * In an effort to simplify the referenced paths and align them with the
   * structure of JSON:API responses and the structure of the hypothetical
   * "reference document" (see link), it is possible to alias field names and
   * elide the "entity" keyword from them (this word is used by the entity query
   * system to traverse entity references).
   *
   * This method takes this external field expression and attempts to resolve
   * any aliases and/or abbreviations into a field expression that will be
   * compatible with the entity query system.
   *
   * @link http://jsonapi.org/recommendations/#urls-reference-document
   *
   * Example:
   *   'uid.field_first_name' -> 'uid.entity.field_first_name'.
   *   'author.firstName' -> 'field_author.entity.field_first_name'
   *
   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
   *   The JSON:API resource type from which to resolve the field name.
   * @param string $external_field_name
   *   The public field name to map to a Drupal field name.
   * @param string $operator
   *   (optional) The operator of the condition for which the path should be
   *   resolved.
   *
   * @return string
   *   The mapped field name.
   *
   * @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
   */
  public function resolveInternalEntityQueryPath(ResourceType $resource_type, $external_field_name, $operator = NULL) {
    $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter', 'url.query_args:sort']);
    if (empty($external_field_name)) {
      throw new CacheableBadRequestHttpException($cacheability, 'No field name was provided for the filter.');
    }

    // Turns 'uid.categories.name' into
    // 'uid.entity.field_category.entity.name'. This may be too simple, but it
    // works for the time being.
    $parts = explode('.', $external_field_name);
    $unresolved_path_parts = $parts;
    $reference_breadcrumbs = [];
    /** @var \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types */
    $resource_types = [$resource_type];
    // This complex expression is needed to handle the string, "0", which would
    // otherwise be evaluated as FALSE.
    while (!is_null(($part = array_shift($parts)))) {
      if (!$this->isMemberFilterable($part, $resource_types)) {
        throw new CacheableBadRequestHttpException($cacheability, sprintf(
          'Invalid nested filtering. The field `%s`, given in the path `%s`, does not exist.',
          $part,
          $external_field_name
        ));
      }

      $field_name = $this->getInternalName($part, $resource_types);

      // If none of the resource types are traversable, assume that the
      // remaining path parts are targeting field deltas and/or field
      // properties.
      if (!$this->resourceTypesAreTraversable($resource_types)) {
        $reference_breadcrumbs[] = $field_name;
        return $this->constructInternalPath($reference_breadcrumbs, $parts);
      }

      // Different resource types have different field definitions.
      $candidate_definitions = $this->getFieldItemDefinitions($resource_types, $field_name);
      assert(!empty($candidate_definitions));

      // We have a valid field, so add it to the validated trail of path parts.
      $reference_breadcrumbs[] = $field_name;

      // Remove resource types which do not have a candidate definition.
      $resource_types = array_filter($resource_types, function (ResourceType $resource_type) use ($candidate_definitions) {
        return isset($candidate_definitions[$resource_type->getTypeName()]);
      });

      // Check access to execute a query for each field per resource type since
      // field definitions are bundle-specific.
      foreach ($resource_types as $resource_type) {
        $field_access = $this->getFieldAccess($resource_type, $field_name);
        $cacheability->addCacheableDependency($field_access);
        if (!$field_access->isAllowed()) {
          $message = sprintf('The current user is not authorized to filter by the `%s` field, given in the path `%s`.', $field_name, implode('.', $reference_breadcrumbs));
          if ($field_access instanceof AccessResultReasonInterface && ($reason = $field_access->getReason()) && !empty($reason)) {
            $message .= ' ' . $reason;
          }
          throw new CacheableAccessDeniedHttpException($cacheability, $message);
        }
      }

      // Get all of the referenceable resource types.
      $resource_types = $this->getRelatableResourceTypes($resource_types, $candidate_definitions);

      $at_least_one_entity_reference_field = FALSE;
      $candidate_property_names = array_unique(NestedArray::mergeDeepArray(array_map(function (FieldItemDataDefinitionInterface $definition) use (&$at_least_one_entity_reference_field) {
        $property_definitions = $definition->getPropertyDefinitions();
        return array_reduce(array_keys($property_definitions), function ($property_names, $property_name) use ($property_definitions, &$at_least_one_entity_reference_field) {
          $property_definition = $property_definitions[$property_name];
          $is_data_reference_definition = $property_definition instanceof DataReferenceTargetDefinition;
          if (!$property_definition->isInternal()) {
            // Entity reference fields are special: their reference property
            // (usually `target_id`) is exposed in the JSON:API representation
            // with a prefix.
            $property_names[] = $is_data_reference_definition ? 'id' : $property_name;
          }
          if ($is_data_reference_definition) {
            $at_least_one_entity_reference_field = TRUE;
            $property_names[] = "drupal_internal__$property_name";
          }
          return $property_names;
        }, []);
      }, $candidate_definitions)));

      // Determine if the specified field has one property or many in its
      // JSON:API representation, or if it is an relationship (an entity
      // reference field), in which case the `id` of the related resource must
      // always be specified.
      $property_specifier_needed = $at_least_one_entity_reference_field || count($candidate_property_names) > 1;

      // If there are no remaining path parts, the process is finished unless
      // the field has multiple properties, in which case one must be specified.
      if (empty($parts)) {
        // If the operator is asserting the presence or absence of a
        // relationship entirely, it does not make sense to require a property
        // specifier.
        if ($property_specifier_needed && (!$at_least_one_entity_reference_field || !in_array($operator, ['IS NULL', 'IS NOT NULL'], TRUE))) {
          $possible_specifiers = array_map(function ($specifier) use ($at_least_one_entity_reference_field) {
            return $at_least_one_entity_reference_field && $specifier !== 'id' ? "meta.$specifier" : $specifier;
          }, $candidate_property_names);
          throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The field `%s`, given in the path `%s` is incomplete, it must end with one of the following specifiers: `%s`.', $part, $external_field_name, implode('`, `', $possible_specifiers)));
        }
        return $this->constructInternalPath($reference_breadcrumbs);
      }

      // If the next part is a delta, as in "body.0.value", then we add it to
      // the breadcrumbs and remove it from the parts that still must be
      // processed.
      if (static::isDelta($parts[0])) {
        $reference_breadcrumbs[] = array_shift($parts);
      }

      // If there are no remaining path parts, the process is finished.
      if (empty($parts)) {
        return $this->constructInternalPath($reference_breadcrumbs);
      }

      // JSON:API outputs entity reference field properties under a meta object
      // on a relationship. If the filter specifies one of these properties, it
      // must prefix the property name with `meta`. The only exception is if the
      // next path part is the same as the name for the reference property
      // (typically `entity`), this is permitted to disambiguate the case of a
      // field name on the target entity which is the same a property name on
      // the entity reference field.
      if ($at_least_one_entity_reference_field && $parts[0] !== 'id') {
        if ($parts[0] === 'meta') {
          array_shift($parts);
        }
        elseif (in_array($parts[0], $candidate_property_names) && !static::isCandidateDefinitionReferenceProperty($parts[0], $candidate_definitions)) {
          throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The property `%s`, given in the path `%s` belongs to the meta object of a relationship and must be preceded by `meta`.', $parts[0], $external_field_name));
        }
      }

      // Determine if the next part is not a property of $field_name.
      if (!static::isCandidateDefinitionProperty($parts[0], $candidate_definitions) && !empty(static::getAllDataReferencePropertyNames($candidate_definitions))) {
        // The next path part is neither a delta nor a field property, so it
        // must be a field on a targeted resource type. We need to guess the
        // intermediate reference property since one was not provided.
        //
        // For example, the path `uid.name` for a `node--article` resource type
        // will be resolved into `uid.entity.name`.
        $reference_breadcrumbs[] = static::getDataReferencePropertyName($candidate_definitions, $parts, $unresolved_path_parts);
      }
      else {
        // If the property is not a reference property, then all
        // remaining parts must be further property specifiers.
        if (!static::isCandidateDefinitionReferenceProperty($parts[0], $candidate_definitions)) {
          // If a field property is specified on a field with only one property
          // defined, throw an error because in the JSON:API output, it does not
          // exist. This is because JSON:API elides single-value properties;
          // respecting it would leak this Drupalism out.
          if (count($candidate_property_names) === 1) {
            throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The property `%s`, given in the path `%s`, does not exist. Filter by `%s`, not `%s` (the JSON:API module elides property names from single-property fields).', $parts[0], $external_field_name, substr($external_field_name, 0, strlen($external_field_name) - strlen($parts[0]) - 1), $external_field_name));
          }
          elseif (!in_array($parts[0], $candidate_property_names, TRUE)) {
            throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The property `%s`, given in the path `%s`, does not exist. Must be one of the following property names: `%s`.', $parts[0], $external_field_name, implode('`, `', $candidate_property_names)));
          }
          return $this->constructInternalPath($reference_breadcrumbs, $parts);
        }
        // The property is a reference, so add it to the breadcrumbs and
        // continue resolving fields.
        $reference_breadcrumbs[] = array_shift($parts);
      }
    }

    // Reconstruct the full path to the final reference field.
    return $this->constructInternalPath($reference_breadcrumbs);
  }

  /**
   * Expands the internal path with the "entity" keyword.
   *
   * @param string[] $references
   *   The resolved internal field names of all entity references.
   * @param string[] $property_path
   *   (optional) A sub-property path for the last field in the path.
   *
   * @return string
   *   The expanded and imploded path.
   */
  protected function constructInternalPath(array $references, array $property_path = []) {
    // Reconstruct the path parts that are referencing sub-properties.
    $field_path = implode('.', array_map(function ($part) {
      return str_replace('drupal_internal__', '', $part);
    }, $property_path));

    // This rebuilds the path from the real, internal field names that have
    // been traversed so far. It joins them with the "entity" keyword as
    // required by the entity query system.
    $entity_path = implode('.', $references);

    // Reconstruct the full path to the final reference field.
    return (empty($field_path)) ? $entity_path : $entity_path . '.' . $field_path;
  }

  /**
   * Get all item definitions from a set of resources types by a field name.
   *
   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
   *   The resource types on which the field might exist.
   * @param string $field_name
   *   The field for which to retrieve field item definitions.
   *
   * @return \Drupal\Core\TypedData\ComplexDataDefinitionInterface[]
   *   The found field item definitions.
   */
  protected function getFieldItemDefinitions(array $resource_types, $field_name) {
    return array_reduce($resource_types, function ($result, ResourceType $resource_type) use ($field_name) {
      /** @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
      $entity_type = $resource_type->getEntityTypeId();
      $bundle = $resource_type->getBundle();
      $definitions = $this->fieldManager->getFieldDefinitions($entity_type, $bundle);
      if (isset($definitions[$field_name])) {
        $result[$resource_type->getTypeName()] = $definitions[$field_name]->getItemDefinition();
      }
      return $result;
    }, []);
  }

  /**
   * Resolves the UUID field name for a resource type.
   *
   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
   *   The resource type for which to get the UUID field name.
   *
   * @return string
   *   The resolved internal name.
   */
  protected function getIdFieldName(ResourceType $resource_type) {
    $entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
    return $entity_type->getKey('uuid');
  }

  /**
   * Resolves the internal field name based on a collection of resource types.
   *
   * @param string $field_name
   *   The external field name.
   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
   *   The resource types from which to get an internal name.
   *
   * @return string
   *   The resolved internal name.
   */
  protected function getInternalName($field_name, array $resource_types) {
    return array_reduce($resource_types, function ($carry, ResourceType $resource_type) use ($field_name) {
      if ($carry != $field_name) {
        // We already found the internal name.
        return $carry;
      }
      return $field_name === 'id' ? $this->getIdFieldName($resource_type) : $resource_type->getInternalName($field_name);
    }, $field_name);
  }

  /**
   * Determines if the given field or member name is filterable.
   *
   * @param string $external_name
   *   The external field or member name.
   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
   *   The resource types to test.
   *
   * @return bool
   *   Whether the given field is present as a filterable member of the targeted
   *   resource objects.
   */
  protected function isMemberFilterable($external_name, array $resource_types) {
    return array_reduce($resource_types, function ($carry, ResourceType $resource_type) use ($external_name) {
      // @todo Remove the next line and uncomment the following one in
      //   https://www.drupal.org/project/drupal/issues/3017047.
      return $carry ?: $external_name === 'id' || $resource_type->isFieldEnabled($resource_type->getInternalName($external_name));
      /*return $carry ?: in_array($external_name, ['id', 'type']) || $resource_type->isFieldEnabled($resource_type->getInternalName($external_name));*/
    }, FALSE);
  }

  /**
   * Get the referenceable ResourceTypes for a set of field definitions.
   *
   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
   *   The resource types on which the reference field might exist.
   * @param \Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface[] $definitions
   *   The field item definitions of targeted fields, keyed by the resource
   *   type name on which they reside.
   *
   * @return \Drupal\jsonapi\ResourceType\ResourceType[]
   *   The referenceable target resource types.
   */
  protected function getRelatableResourceTypes(array $resource_types, array $definitions) {
    $relatable_resource_types = [];
    foreach ($resource_types as $resource_type) {
      $definition = $definitions[$resource_type->getTypeName()];
      $resource_type_field = $resource_type->getFieldByInternalName($definition->getFieldDefinition()->getName());
      if ($resource_type_field instanceof ResourceTypeRelationship) {
        foreach ($resource_type_field->getRelatableResourceTypes() as $relatable_resource_type) {
          $relatable_resource_types[$relatable_resource_type->getTypeName()] = $relatable_resource_type;
        }
      }
    }
    return $relatable_resource_types;
  }

  /**
   * Whether the given resources can be traversed to other resources.
   *
   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
   *   The resources types to evaluate.
   *
   * @return bool
   *   TRUE if any one of the given resource types is traversable.
   *
   * @todo This class shouldn't be aware of entity types and their definitions.
   * Whether a resource can have relationships to other resources is information
   * we ought to be able to discover on the ResourceType. However, we cannot
   * reliably determine this information with existing APIs. Entities may be
   * backed by various storages that are unable to perform queries across
   * references and certain storages may not be able to store references at all.
   */
  protected function resourceTypesAreTraversable(array $resource_types) {
    foreach ($resource_types as $resource_type) {
      $entity_type_definition = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
      if ($entity_type_definition->entityClassImplements(FieldableEntityInterface::class)) {
        return TRUE;
      }
    }
    return FALSE;
  }

  /**
   * Gets all unique reference property names from the given field definitions.
   *
   * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
   *   A list of targeted field item definitions specified by the path.
   *
   * @return string[]
   *   The reference property names, if any.
   */
  protected static function getAllDataReferencePropertyNames(array $candidate_definitions) {
    $reference_property_names = array_reduce($candidate_definitions, function (array $reference_property_names, ComplexDataDefinitionInterface $definition) {
      $property_definitions = $definition->getPropertyDefinitions();
      foreach ($property_definitions as $property_name => $property_definition) {
        if ($property_definition instanceof DataReferenceDefinitionInterface) {
          $target_definition = $property_definition->getTargetDefinition();
          assert($target_definition instanceof EntityDataDefinitionInterface, 'Entity reference fields should only be able to reference entities.');
          $reference_property_names[] = $property_name . ':' . $target_definition->getEntityTypeId();
        }
      }
      return $reference_property_names;
    }, []);
    return array_unique($reference_property_names);
  }

  /**
   * Determines the reference property name for the remaining unresolved parts.
   *
   * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
   *   A list of targeted field item definitions specified by the path.
   * @param string[] $remaining_parts
   *   The remaining path parts.
   * @param string[] $unresolved_path_parts
   *   The unresolved path parts.
   *
   * @return string
   *   The reference name.
   */
  protected static function getDataReferencePropertyName(array $candidate_definitions, array $remaining_parts, array $unresolved_path_parts) {
    $unique_reference_names = static::getAllDataReferencePropertyNames($candidate_definitions);
    if (count($unique_reference_names) > 1) {
      $choices = array_map(function ($reference_name) use ($unresolved_path_parts, $remaining_parts) {
        $prior_parts = array_slice($unresolved_path_parts, 0, count($unresolved_path_parts) - count($remaining_parts));
        return implode('.', array_merge($prior_parts, [$reference_name], $remaining_parts));
      }, $unique_reference_names);
      // @todo Add test coverage for this in https://www.drupal.org/project/drupal/issues/2971281
      $message = sprintf('Ambiguous path. Try one of the following: %s, in place of the given path: %s', implode(', ', $choices), implode('.', $unresolved_path_parts));
      $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter', 'url.query_args:sort']);
      throw new CacheableBadRequestHttpException($cacheability, $message);
    }
    return $unique_reference_names[0];
  }

  /**
   * Determines if a path part targets a specific field delta.
   *
   * @param string $part
   *   The path part.
   *
   * @return bool
   *   TRUE if the part is an integer, FALSE otherwise.
   */
  protected static function isDelta($part) {
    return (bool) preg_match('/^[0-9]+$/', $part);
  }

  /**
   * Determines if a path part targets a field property, not a subsequent field.
   *
   * @param string $part
   *   The path part.
   * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
   *   A list of targeted field item definitions which are specified by the
   *   path.
   *
   * @return bool
   *   TRUE if the part is a property of one of the candidate definitions, FALSE
   *   otherwise.
   */
  protected static function isCandidateDefinitionProperty($part, array $candidate_definitions) {
    $part = static::getPathPartPropertyName($part);
    foreach ($candidate_definitions as $definition) {
      $property_definitions = $definition->getPropertyDefinitions();

      foreach ($property_definitions as $property_name => $property_definition) {
        $property_name = $property_definition instanceof DataReferenceTargetDefinition
          ? "drupal_internal__$property_name"
          : $property_name;
        if ($part === $property_name) {
          return TRUE;
        }
      }
    }
    return FALSE;
  }

  /**
   * Determines if a path part targets a reference property.
   *
   * @param string $part
   *   The path part.
   * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
   *   A list of targeted field item definitions which are specified by the
   *   path.
   *
   * @return bool
   *   TRUE if the part is a property of one of the candidate definitions, FALSE
   *   otherwise.
   */
  protected static function isCandidateDefinitionReferenceProperty($part, array $candidate_definitions) {
    $part = static::getPathPartPropertyName($part);
    foreach ($candidate_definitions as $definition) {
      $property = $definition->getPropertyDefinition($part);
      if ($property && $property instanceof DataReferenceDefinitionInterface) {
        return TRUE;
      }
    }
    return FALSE;
  }

  /**
   * Gets the property name from an entity typed or untyped path part.
   *
   * A path part may contain an entity type specifier like `entity:node`. This
   * extracts the actual property name. If an entity type is not specified, then
   * the path part is simply returned. For example, both `foo` and `foo:bar`
   * will return `foo`.
   *
   * @param string $part
   *   A path part.
   *
   * @return string
   *   The property name from a path part.
   */
  protected static function getPathPartPropertyName($part) {
    return str_contains($part, ':') ? explode(':', $part)[0] : $part;
  }

  /**
   * Gets the field access result for the 'view' operation.
   *
   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
   *   The JSON:API resource type on which the field exists.
   * @param string $internal_field_name
   *   The field name for which access should be checked.
   *
   * @return \Drupal\Core\Access\AccessResultInterface
   *   The 'view' access result.
   */
  protected function getFieldAccess(ResourceType $resource_type, $internal_field_name) {
    $definitions = $this->fieldManager->getFieldDefinitions($resource_type->getEntityTypeId(), $resource_type->getBundle());
    assert(isset($definitions[$internal_field_name]), 'The field name should have already been validated.');
    $field_definition = $definitions[$internal_field_name];
    $filter_access_results = $this->moduleHandler->invokeAll('jsonapi_entity_field_filter_access', [$field_definition, $this->currentUser]);
    $filter_access_result = array_reduce($filter_access_results, function (AccessResultInterface $combined_result, AccessResultInterface $result) {
      return $combined_result->orIf($result);
    }, AccessResult::neutral());
    if (!$filter_access_result->isNeutral()) {
      return $filter_access_result;
    }
    $entity_access_control_handler = $this->entityTypeManager->getAccessControlHandler($resource_type->getEntityTypeId());
    $field_access = $entity_access_control_handler->fieldAccess('view', $field_definition, NULL, NULL, TRUE);
    return $filter_access_result->orIf($field_access);
  }

}
