<?php

namespace Drupal\experience_builder\Controller;

use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\experience_builder\AutoSave\AutoSaveManager;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

/**
 * Trait providing client auto-save validation infrastructure for controllers.
 *
 * This trait is used to validate that the client is aware of the latest
 * auto-saves for both:
 * 1. data part of the page preview:
 *    - the component tree (`layout` + `model` in the client-side representation)
 *    - values for all entity fields besides the XB field (aka component tree)
 *    - PageRegion config entities
 * 2. config entities which are explicitly edited and auto-saved
 *    - JavaScriptComponent config entities
 *    - AssetLibrary config entities.
 *
 * @see \Drupal\experience_builder\Controller\ApiLayoutController
 * @see \Drupal\experience_builder\Controller\ApiConfigAutoSaveControllers
 * @internal
 */
trait AutoSaveValidateTrait {

  /**
   * Validates the client has the latest auto-saves for an array of entities.
   *
   * Currently, Experience Builder does not support concurrent editing. This
   * function enforces this by ensuring the client has the latest changes as
   * determined by the auto-save data stored on the server.
   *
   * The client must return the 'autoSaves' array that is returned from this
   * trait. The 'autoSaves' array contains a hash of the auto-save data
   * and an 'autoSaveStartingPoint' for all entities that may be updated in the
   * current request. See ::getClientAutoSaveData() for more details.
   *
   * The client also sends a `clientInstanceId` which is a UUID generated by the
   * client that is unique to the client instance. To handle edge cases where a
   * client request is sent before the last request has returned with the latest
   * auto-save hashes, if the `clientInstanceId` matches the value in the
   * server's auto-save entry, then the 'hash' values is 'autoSaves' is ignored
   * and only the 'autoSaveStartingPoint' values must match.
   *
   * @see ::getClientAutoSaveData()
   */
  protected function validateAutoSaves(array $entitiesToValidate, array $autoSaves, string $clientId): void {
    $expected_auto_saves = [];
    $foundClientIdMismatch = FALSE;
    foreach ($entitiesToValidate as $entityToValidate) {
      assert($entityToValidate instanceof EntityInterface);
      $autoSaveEntity = $this->autoSaveManager->getAutoSaveEntity($entityToValidate);
      $expected_auto_saves[AutoSaveManager::getAutoSaveKey($entityToValidate)] = $this->getClientAutoSaveData($entityToValidate);
      $foundClientIdMismatch = $foundClientIdMismatch || (!$autoSaveEntity->isEmpty() && $autoSaveEntity->clientId !== $clientId);
    }
    $expected_auto_saves = array_filter($expected_auto_saves);
    ksort($expected_auto_saves);
    ksort($autoSaves);
    if (!$foundClientIdMismatch) {
      // If the client ID matches, we can skip the check for the auto-saves
      // hashes. Client requests may be sent off before the last request returns.
      // Remove 'hash' from the expected auto-saves and actual ones. But we
      // should still make sure the 'autoSaveStartingPoint' matches.
      array_walk($expected_auto_saves, function (&$value) {
        unset($value['hash']);
      });
      array_walk($autoSaves, function (&$value) {
        unset($value['hash']);
      });
    }

    if ($expected_auto_saves !== $autoSaves) {
      throw new ConflictHttpException('You do not have the latest changes, please refresh your browser.');
    }
  }

  /**
   * @param \Drupal\Core\Entity\EntityInterface $entity
   * @return array{autoSaveStartingPoint: int|string|null, hash: string|null}
   */
  protected function getClientAutoSaveData(EntityInterface $entity): array {
    $autoSaveEntity = $this->autoSaveManager->getAutoSaveEntity($entity);
    // We must use the saved entity to get the revision ID and changed time
    // because these values might have changed in the auto-save version.
    \assert($entity->id() !== NULL);
    $savedEntity = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadUnchanged($entity->id());
    assert($savedEntity instanceof EntityInterface);
    // If available we must use the revision ID and the changed time because
    // not all entity types will increment the revision ID on every change.
    // @todo Move this logic elsewhere in https://www.drupal.org/project/experience_builder/issues/3535458
    $autoSaveStartRevision = $savedEntity instanceof RevisionableInterface ?
      $savedEntity->getRevisionId() :
      \hash('xxh64', \json_encode($entity->toArray(), JSON_THROW_ON_ERROR));
    if ($savedEntity instanceof EntityChangedInterface) {
      $autoSaveStartRevision .= '-' . $savedEntity->getChangedTime();
    }
    return [
      'autoSaveStartingPoint' => $autoSaveStartRevision,
      'hash' => $autoSaveEntity->hash,
    ];
  }

  protected function getAutoSaveHashes(array $entities): array {
    $autoSaveHashes = [];
    foreach ($entities as $entity) {
      \assert($entity instanceof EntityInterface);
      $autoSaveHashes[AutoSaveManager::getAutoSaveKey($entity)] = $this->getClientAutoSaveData($entity);
    }
    return array_filter($autoSaveHashes);
  }

}
