<?php

namespace Drupal\Core\Entity;

use Drupal\Core\Entity\Query\QueryInterface;
use Drupal\Core\Cache\MemoryCache\MemoryCacheInterface;
use Drupal\Core\Utility\FiberResumeType;

/**
 * A base entity storage class.
 */
abstract class EntityStorageBase extends EntityHandlerBase implements EntityStorageInterface, EntityHandlerInterface {

  /**
   * Entity type ID for this storage.
   *
   * @var string
   */
  protected $entityTypeId;

  /**
   * Information about the entity type.
   *
   * @var \Drupal\Core\Entity\EntityTypeInterface
   *
   * The following code returns the same object:
   * @code
   * \Drupal::entityTypeManager()->getDefinition($this->entityTypeId)
   * @endcode
   */
  protected $entityType;

  /**
   * Name of the entity's ID field in the entity database table.
   *
   * @var string
   */
  protected $idKey;

  /**
   * Name of entity's UUID database table field, if it supports UUIDs.
   *
   * Has the value FALSE if this entity does not use UUIDs.
   *
   * @var string
   */
  protected $uuidKey;

  /**
   * The name of the entity langcode property.
   *
   * @var string
   */
  protected $langcodeKey;

  /**
   * The UUID service.
   *
   * @var \Drupal\Component\Uuid\UuidInterface
   */
  protected $uuidService;

  /**
   * Name of the base entity class.
   *
   * This is a private property since it's not meant to be set by child classes.
   * It holds the name of the entity class defined in the entity type that is
   * passed in to the constructor when instantiating an entity storage class.
   *
   * Normally, the entity class is defined via an annotation when defining an
   * entity type, via hook_entity_bundle_info() or via
   * hook_entity_bundle_info_alter(). However, due to how this property works,
   * the entity class can also be controlled via hook_entity_type_alter().
   *
   * @var string|null
   */
  private $baseEntityClass;

  /**
   * The memory cache.
   *
   * @var \Drupal\Core\Cache\MemoryCache\MemoryCacheInterface
   */
  protected $memoryCache;

  /**
   * The memory cache tag.
   *
   * @var string
   */
  protected $memoryCacheTag;

  /**
   * Entity IDs awaiting loading.
   */
  protected array $entityIdsToLoad = [];

  /**
   * Constructs an EntityStorageBase instance.
   *
   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
   *   The entity type definition.
   * @param \Drupal\Core\Cache\MemoryCache\MemoryCacheInterface $memory_cache
   *   The memory cache.
   */
  public function __construct(EntityTypeInterface $entity_type, MemoryCacheInterface $memory_cache) {
    $this->entityTypeId = $entity_type->id();
    $this->entityType = $entity_type;
    $this->baseEntityClass = $entity_type->getClass();
    $this->idKey = $this->entityType->getKey('id');
    $this->uuidKey = $this->entityType->getKey('uuid');
    $this->langcodeKey = $this->entityType->getKey('langcode');
    $this->memoryCache = $memory_cache;
    $this->memoryCacheTag = 'entity.memory_cache:' . $this->entityTypeId;
  }

  /**
   * {@inheritdoc}
   */
  public function getEntityClass(?string $bundle = NULL): string {
    return $this->baseEntityClass;
  }

  /**
   * {@inheritdoc}
   */
  public function getEntityTypeId() {
    return $this->entityTypeId;
  }

  /**
   * {@inheritdoc}
   */
  public function getEntityType() {
    return $this->entityType;
  }

  /**
   * Builds the cache ID for the passed in entity ID.
   *
   * @param int $id
   *   Entity ID for which the cache ID should be built.
   *
   * @return string
   *   Cache ID that can be passed to the cache backend.
   */
  protected function buildCacheId($id) {
    return "values:{$this->entityTypeId}:$id";
  }

  /**
   * {@inheritdoc}
   */
  public function loadUnchanged($id) {
    $this->resetCache([$id]);
    return $this->load($id);
  }

  /**
   * {@inheritdoc}
   */
  public function resetCache(?array $ids = NULL) {
    if ($this->entityType->isStaticallyCacheable() && isset($ids)) {
      foreach ($ids as $id) {
        $this->memoryCache->delete($this->buildCacheId($id));
      }
    }
    else {
      // Call the backend method directly.
      $this->memoryCache->invalidateTags([$this->memoryCacheTag]);
    }
  }

  /**
   * Gets entities from the static cache.
   *
   * @param array $ids
   *   If not empty, return entities that match these IDs.
   *
   * @return \Drupal\Core\Entity\EntityInterface[]
   *   Array of entities from the entity cache, keyed by entity ID.
   */
  protected function getFromStaticCache(array $ids) {
    $entities = [];
    // Load any available entities from the internal cache.
    foreach ($ids as $id) {
      if ($cached = $this->memoryCache->get($this->buildCacheId($id))) {
        $entities[$id] = $cached->data;
      }
    }
    return $entities;
  }

  /**
   * Stores entities in the static entity cache.
   *
   * @param \Drupal\Core\Entity\EntityInterface[] $entities
   *   Entities to store in the cache.
   */
  protected function setStaticCache(array $entities) {
    if ($this->entityType->isStaticallyCacheable()) {
      foreach ($entities as $entity) {
        $this->memoryCache->set($this->buildCacheId($entity->id()), $entity, MemoryCacheInterface::CACHE_PERMANENT, [$this->memoryCacheTag]);
      }
    }
  }

  /**
   * Invokes a hook on behalf of the entity.
   *
   * @param string $hook
   *   One of 'create', 'presave', 'insert', 'update', 'predelete', 'delete', or
   *   'revision_delete'.
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity object.
   */
  protected function invokeHook($hook, EntityInterface $entity) {
    // Invoke the hook.
    $this->moduleHandler()->invokeAll($this->entityTypeId . '_' . $hook, [$entity]);
    // Invoke the respective entity-level hook.
    $this->moduleHandler()->invokeAll('entity_' . $hook, [$entity]);
  }

  /**
   * {@inheritdoc}
   */
  public function create(array $values = []) {
    $entity_class = $this->getEntityClass();
    $entity_class::preCreate($this, $values);

    // Assign a new UUID if there is none yet.
    if ($this->uuidKey && $this->uuidService && !isset($values[$this->uuidKey])) {
      $values[$this->uuidKey] = $this->uuidService->generate();
    }

    $entity = $this->doCreate($values);
    $entity->enforceIsNew();

    $entity->postCreate($this);

    // Modules might need to add or change the data initially held by the new
    // entity object, for instance to fill-in default values.
    $this->invokeHook('create', $entity);

    return $entity;
  }

  /**
   * Performs storage-specific creation of entities.
   *
   * @param array $values
   *   An array of values to set, keyed by property name.
   *
   * @return \Drupal\Core\Entity\EntityInterface
   *   The created entity.
   */
  protected function doCreate(array $values) {
    $entity_class = $this->getEntityClass();
    return new $entity_class($values, $this->entityTypeId);
  }

  /**
   * {@inheritdoc}
   */
  public function load($id) {
    assert(!is_null($id), sprintf('Cannot load the "%s" entity with NULL ID.', $this->entityTypeId));
    $entities = $this->loadMultiple([$id]);
    return $entities[$id] ?? NULL;
  }

  /**
   * {@inheritdoc}
   */
  public function loadMultiple(?array $ids = NULL) {
    $entities = [];
    $preloaded_entities = [];

    // Create a new variable which is either a prepared version of the $ids
    // array for later comparison with the entity cache, or FALSE if no $ids
    // were passed. The $ids array is reduced as items are loaded from cache,
    // and we need to know if it is empty for this reason to avoid querying the
    // database when all requested entities are loaded from cache.
    $flipped_ids = $ids ? array_flip($ids) : FALSE;
    // Try to load entities from the static cache, if the entity type supports
    // static caching.
    if ($ids && $this->entityType->isStaticallyCacheable()) {
      $entities += $this->getFromStaticCache($ids);
      // If any entities were in the static cache remove them from the
      // remaining IDs.
      $ids = array_diff($ids, array_keys($entities));

      $fiber = \Fiber::getCurrent();
      if ($ids && $fiber !== NULL) {
        // Before suspending the fiber, add the IDs passed in to the full list
        // of entities to load, so that another call can load everything at
        // once.
        $this->entityIdsToLoad = array_unique(array_merge($this->entityIdsToLoad, $ids));
        $fiber->suspend(FiberResumeType::Immediate);

        // If all the IDs we need to return have already been loaded into the
        // static cache, ignore any additionally requested entities here since
        // deferring these may allow them to be loaded with more other entities
        // later.
        $entities += $this->getFromStaticCache($ids);
        $ids = array_diff($ids, array_keys($entities));

        // Otherwise load additional entities now.
        if ($ids && $this->entityIdsToLoad) {
          $ids = array_unique(array_merge($ids, $this->entityIdsToLoad));
          $this->entityIdsToLoad = [];
        }
      }
    }

    // Try to gather any remaining entities from a 'preload' method. This method
    // can invoke a hook to be used by modules that need, for example, to swap
    // the default revision of an entity with a different one. Even though the
    // base entity storage class does not actually invoke any preload hooks, we
    // need to call the method here so we can add the pre-loaded entity objects
    // to the static cache below. If all the entities were fetched from the
    // static cache, skip this step.
    if ($ids === NULL || $ids) {
      $preloaded_entities = $this->preLoad($ids);
    }
    if (!empty($preloaded_entities)) {
      $entities += $preloaded_entities;

      // If any entities were pre-loaded, remove them from the IDs still to
      // load.
      $ids = array_diff($ids, array_keys($entities));

      // Add pre-loaded entities to the cache.
      $this->setStaticCache($preloaded_entities);
    }

    // Load any remaining entities from the database. This is the case if $ids
    // is set to NULL (so we load all entities) or if there are any IDs left to
    // load.
    if ($ids === NULL || $ids) {
      $queried_entities = $this->doLoadMultiple($ids);
    }

    // Pass all entities loaded from the database through $this->postLoad(),
    // which attaches fields (if supported by the entity type) and calls the
    // entity type specific load callback, for example hook_node_load().
    if (!empty($queried_entities)) {
      $this->postLoad($queried_entities);
      $entities += $queried_entities;

      // Add queried entities to the cache.
      $this->setStaticCache($queried_entities);
    }

    if ($flipped_ids) {
      // When IDs were passed in, ensure only entities that were loaded by this
      // specific method call (e.g. not for other Fibers) are returned, and that
      // any entities that could not be loaded are removed.
      foreach ($flipped_ids as $entity_id => $value) {
        if (isset($entities[$entity_id])) {
          $flipped_ids[$entity_id] = $entities[$entity_id];
        }
        else {
          unset($flipped_ids[$entity_id]);
        }
      }
      $entities = $flipped_ids;
    }

    return $entities;
  }

  /**
   * Performs storage-specific loading of entities.
   *
   * Override this method to add custom functionality directly after loading.
   * This is always called, while self::postLoad() is only called when there are
   * actual results.
   *
   * @param array|null $ids
   *   (optional) An array of entity IDs, or NULL to load all entities.
   *
   * @return \Drupal\Core\Entity\EntityInterface[]
   *   Associative array of entities, keyed on the entity ID.
   */
  abstract protected function doLoadMultiple(?array $ids = NULL);

  /**
   * Gathers entities from a 'preload' step.
   *
   * @param array|null &$ids
   *   If not empty, return entities that match these IDs. IDs that were found
   *   will be removed from the list.
   *
   * @return \Drupal\Core\Entity\EntityInterface[]
   *   Associative array of entities, keyed by the entity ID.
   */
  protected function preLoad(?array &$ids = NULL) {
    return [];
  }

  /**
   * Attaches data to entities upon loading.
   *
   * If there are multiple bundle classes involved, each one gets a sub array
   * with only the entities of the same bundle. If there's only a single bundle,
   * the entity's postLoad() method will get a copy of the original $entities
   * array.
   *
   * @param array $entities
   *   Associative array of query results, keyed on the entity ID.
   */
  protected function postLoad(array &$entities) {
    $entities_by_class = $this->getEntitiesByClass($entities);

    // Invoke entity class specific postLoad() methods. If there's only a single
    // class involved, we want to pass in the original $entities array. For
    // example, to provide backwards compatibility with the legacy behavior of
    // the deprecated user_roles() method, \Drupal\user\Entity\Role::postLoad()
    // sorts the array to enforce role weights. We have to let it manipulate the
    // final array, not a subarray. However if there are multiple bundle classes
    // involved, we only want to pass each one the entities that match.
    if (count($entities_by_class) === 1) {
      $entity_class = array_key_first($entities_by_class);
      $entity_class::postLoad($this, $entities);
    }
    else {
      foreach ($entities_by_class as $entity_class => &$items) {
        $entity_class::postLoad($this, $items);
      }
    }
    $this->moduleHandler()->invokeAllWith('entity_load', function (callable $hook, string $module) use (&$entities) {
      $hook($entities, $this->entityTypeId);
    });
    $this->moduleHandler()->invokeAllWith($this->entityTypeId . '_load', function (callable $hook, string $module) use (&$entities) {
      $hook($entities);
    });
  }

  /**
   * Maps from storage records to entity objects.
   *
   * @param array $records
   *   Associative array of query results, keyed on the entity ID.
   *
   * @return \Drupal\Core\Entity\EntityInterface[]
   *   An array of entity objects implementing the EntityInterface.
   */
  protected function mapFromStorageRecords(array $records) {
    $entities = [];
    foreach ($records as $record) {
      $entity_class = $this->getEntityClass();
      /** @var \Drupal\Core\Entity\EntityInterface $entity */
      $entity = new $entity_class($record, $this->entityTypeId);
      $entity_id = $entity->id();
      if ($entity_id === NULL) {
        throw new EntityMalformedException('The entity does not have an ID.');
      }
      $entities[$entity_id] = $entity;
    }
    return $entities;
  }

  /**
   * Determines if this entity already exists in storage.
   *
   * @param int|string $id
   *   The original entity ID.
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity being saved.
   *
   * @return bool
   *   TRUE if this entity exists in storage, FALSE otherwise.
   */
  abstract protected function has($id, EntityInterface $entity);

  /**
   * {@inheritdoc}
   */
  public function delete(array $entities) {
    if (!$entities) {
      // If no entities were passed, do nothing.
      return;
    }

    $entities_by_class = $this->getEntitiesByClass($entities);

    // Allow code to run before deleting.
    foreach ($entities_by_class as $entity_class => &$items) {
      $entity_class::preDelete($this, $items);
      foreach ($items as $entity) {
        $this->invokeHook('predelete', $entity);
      }

      // Perform the delete and reset the static cache for the deleted entities.
      $this->doDelete($items);
      $this->resetCache(array_keys($items));

      // Allow code to run after deleting.
      $entity_class::postDelete($this, $items);
      foreach ($items as $entity) {
        $this->invokeHook('delete', $entity);
      }
    }
  }

  /**
   * Performs storage-specific entity deletion.
   *
   * @param \Drupal\Core\Entity\EntityInterface[] $entities
   *   An array of entity objects to delete.
   */
  abstract protected function doDelete($entities);

  /**
   * {@inheritdoc}
   */
  public function save(EntityInterface $entity) {
    // Track if this entity is new.
    $is_new = $entity->isNew();

    // Execute presave logic and invoke the related hooks.
    $id = $this->doPreSave($entity);

    // Perform the save and reset the static cache for the changed entity.
    $return = $this->doSave($id, $entity);

    // Execute post save logic and invoke the related hooks.
    $this->doPostSave($entity, !$is_new);

    return $return;
  }

  /**
   * Performs presave entity processing.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The saved entity.
   *
   * @return int|string|null
   *   The processed entity identifier, or null for new entities.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   *   If the entity identifier is invalid.
   */
  protected function doPreSave(EntityInterface $entity) {
    $id = $entity->id();

    // Track the original ID.
    if ($entity->getOriginalId() !== NULL) {
      $id = $entity->getOriginalId();
    }

    // Track if this entity exists already.
    $id_exists = $this->has($id, $entity);

    // A new entity should not already exist.
    if ($id_exists && $entity->isNew()) {
      throw new EntityStorageException("'{$this->entityTypeId}' entity with ID '$id' already exists.");
    }

    // Load the original entity, if any.
    if ($id_exists && !$entity->getOriginal()) {
      $entity->setOriginal($this->loadUnchanged($id));
    }

    // Allow code to run before saving.
    $entity->preSave($this);
    $this->invokeHook('presave', $entity);

    return $id;
  }

  /**
   * Performs storage-specific saving of the entity.
   *
   * @param int|string $id
   *   The original entity ID.
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity to save.
   *
   * @return bool|int
   *   If the record insert or update failed, returns FALSE. If it succeeded,
   *   returns SAVED_NEW or SAVED_UPDATED, depending on the operation performed.
   */
  abstract protected function doSave($id, EntityInterface $entity);

  /**
   * Performs post save entity processing.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The saved entity.
   * @param bool $update
   *   Specifies whether the entity is being updated or created.
   */
  protected function doPostSave(EntityInterface $entity, $update) {
    $this->resetCache([$entity->id()]);

    // The entity is no longer new.
    $entity->enforceIsNew(FALSE);

    // Allow code to run after saving.
    $entity->postSave($this, $update);
    $this->invokeHook($update ? 'update' : 'insert', $entity);

    // After saving, this is now the "original entity", and subsequent saves
    // will be updates instead of inserts, and updates must always be able to
    // correctly identify the original entity.
    $entity->setOriginalId($entity->id());

    $entity->setOriginal(NULL);
  }

  /**
   * {@inheritdoc}
   */
  public function restore(EntityInterface $entity) {
    // The restore process does not invoke any pre or post-save operations.
    $this->doSave($entity->id(), $entity);
  }

  /**
   * Builds an entity query.
   *
   * @param \Drupal\Core\Entity\Query\QueryInterface $entity_query
   *   EntityQuery instance.
   * @param array $values
   *   An associative array where the keys are the property names and the
   *   values are the values those properties must have. If a property takes
   *   multiple values, passing an array of values will produce an IN condition.
   */
  protected function buildPropertyQuery(QueryInterface $entity_query, array $values) {
    foreach ($values as $name => $value) {
      // Cast scalars to array so we can consistently use an IN condition.
      $entity_query->condition($name, (array) $value, 'IN');
    }
  }

  /**
   * {@inheritdoc}
   */
  public function loadByProperties(array $values = []) {
    // Build a query to fetch the entity IDs.
    $entity_query = $this->getQuery();
    $entity_query->accessCheck(FALSE);
    $this->buildPropertyQuery($entity_query, $values);
    $result = $entity_query->execute();
    return $result ? $this->loadMultiple($result) : [];
  }

  /**
   * {@inheritdoc}
   */
  public function hasData() {
    return (bool) $this->getQuery()
      ->accessCheck(FALSE)
      ->range(0, 1)
      ->execute();
  }

  /**
   * {@inheritdoc}
   */
  public function getQuery($conjunction = 'AND') {
    return \Drupal::service($this->getQueryServiceName())->get($this->entityType, $conjunction);
  }

  /**
   * {@inheritdoc}
   */
  public function getAggregateQuery($conjunction = 'AND') {
    return \Drupal::service($this->getQueryServiceName())->getAggregate($this->entityType, $conjunction);
  }

  /**
   * Gets the name of the service for the query for this entity storage.
   *
   * @return string
   *   The name of the service for the query for this entity storage.
   */
  abstract protected function getQueryServiceName();

  /**
   * Indexes the given array of entities by their class name and ID.
   *
   * @param \Drupal\Core\Entity\EntityInterface[] $entities
   *   The array of entities to index.
   *
   * @return \Drupal\Core\Entity\EntityInterface[][]
   *   An array of the passed-in entities, indexed by their class name and ID.
   */
  protected function getEntitiesByClass(array $entities): array {
    $entity_classes = [];
    foreach ($entities as $entity) {
      $entity_classes[get_class($entity)][$entity->id()] = $entity;
    }
    return $entity_classes;
  }

}
