<?php

declare(strict_types=1);

namespace Drupal\canvas\Controller;

use Drupal\canvas\Plugin\Field\FieldType\ComponentTreeItem;
use Drupal\canvas\Utility\HomePageHelper;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface;
use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\Query\QueryInterface;
use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Component\Transliteration\TransliterationInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\canvas\AutoSave\AutoSaveManager;
use Drupal\canvas\Entity\Page;
use Drupal\canvas\Resource\OffsetPage;
use Drupal\canvas\Resource\CanvasResourceLink;
use Drupal\canvas\Resource\CanvasResourceLinkCollection;
use Drupal\canvas\CanvasUriDefinitions;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;

/**
 * HTTP API for interacting with Canvas-eligible Content entity types.
 *
 * @internal This HTTP API is intended only for the Canvas UI. These controllers
 *   and associated routes may change at any time.
 *
 * @todo https://www.drupal.org/i/3498525 should generalize this to all eligible content entity types
 */
final class ApiContentControllers extends ApiControllerBase {

  /**
   * The maximum number of entity search results to return.
   */
  private const int MAX_SEARCH_RESULTS = 50;

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly RendererInterface $renderer,
    private readonly AutoSaveManager $autoSaveManager,
    private readonly SelectionPluginManagerInterface $selectionManager,
    private readonly RouteProviderInterface $routeProvider,
    private readonly LanguageManagerInterface $languageManager,
    private readonly AccountProxyInterface $currentUser,
    #[Autowire(service: 'transliteration')]
    private readonly TransliterationInterface $transliteration,
    private readonly HomePageHelper $homePageHelper,
  ) {}

  /**
   * Returns a single Canvas page with its component tree field.
   */
  public function get(Page $canvas_page): CacheableJsonResponse {
    $cacheability = new CacheableMetadata();
    $cacheability->addCacheableDependency($canvas_page);

    $generated_url = $canvas_page->toUrl()->toString(TRUE);
    $cacheability->addCacheableDependency($generated_url);
    $data = $this->normalizeWithComponents($canvas_page, $cacheability);

    $json_response = new CacheableJsonResponse($data);
    $json_response->addCacheableDependency($cacheability);
    return $json_response;
  }

  public function patch(Request $request, ContentEntityInterface $canvas_page): JsonResponse {
    // If there's any auto-saved data, we throw a conflict error.
    $autoSaved = $this->autoSaveManager->getAutoSaveEntity($canvas_page);
    if (!$autoSaved->isEmpty()) {
      throw new ConflictHttpException(Json::encode([
        'error' => \sprintf('%s with ID %s has existing auto-saved data. Please use the UI to publish it or discard it.',
          (string) $canvas_page->getEntityType()->getLabel(),
          $canvas_page->id(),
        ),
      ]));
    }

    // Get the request body content
    $content = $request->getContent();
    $body = \json_decode($content, TRUE);

    // Try to load the entity instance.
    if (!$canvas_page->access('update')) {
      return new JsonResponse(['error' => 'Cannot find entity to update.'], Response::HTTP_NOT_FOUND);
    }
    \assert($canvas_page instanceof Page);

    // Note: this intentionally does not catch content entity type storage
    // handler exceptions: the generic Canvas API exception subscriber handles
    // them.
    // @see \Drupal\canvas\EventSubscriber\ApiExceptionSubscriber

    // Ensure the path field carries the existing PID so Drupal updates the
    // alias in place rather than creating a duplicate.
    if (isset($body['path'])) {
      $existing_pid = $canvas_page->get('path')->first()?->getValue()['pid'] ?? NULL;
      $body['path'] = ['alias' => $body['path'], 'pid' => $existing_pid];
    }

    foreach (['title', 'status', 'path', 'components'] as $field_name) {
      $field_access = $canvas_page->get($field_name)->access(operation: 'edit', return_as_object: TRUE);
      if ($field_access->isForbidden()) {
        throw new CacheableAccessDeniedHttpException(
          (new CacheableMetadata())->addCacheableDependency($field_access),
          \sprintf('Unable to update field %s for entity "%s".', $field_name, $canvas_page->id()),
        );
      }
      $canvas_page->set($field_name, $body[$field_name]);
    }
    $violations = $canvas_page->validate();
    if ($violations->count() > 0) {
      if ($validation_errors_response = self::createJsonResponseFromViolationSets($violations)) {
        return $validation_errors_response;
      }
    }
    $canvas_page->save();

    // The response is never cacheable, so it will be discarded.
    $data = $this->normalizeWithComponents($canvas_page, new CacheableMetadata());

    return new JsonResponse($data, Response::HTTP_OK);
  }

  public function post(Request $request, string $entity_type): JsonResponse {
    // Get the request body content
    $content = $request->getContent();
    $body = json_decode($content, TRUE);
    $entity = NULL;

    // Try to load the entity instance.
    if (isset($body['entity_id'])) {
      $entity = $this->entityTypeManager->getStorage($entity_type)->load($body['entity_id']);
      if (!$entity instanceof ContentEntityInterface || !$entity->access('view')) {
        return new JsonResponse(['error' => 'Cannot find entity to duplicate.'], Response::HTTP_NOT_FOUND);
      }
    }

    // If entity is provided, duplicate it, otherwise create a new entity.
    if ($entity) {
      $new = $this->duplicate($entity);
    }
    else {
      // Note: this intentionally does not catch content entity type storage
      // handler exceptions: the generic Canvas API exception subscriber handles
      // them.
      // @see \Drupal\canvas\EventSubscriber\ApiExceptionSubscriber
      $entity_type_definition = $this->entityTypeManager->getDefinition($entity_type);
      $requestBodyTitle = $body['title'] ?? static::defaultTitle($entity_type_definition);
      $requestStatus = $body['status'] ?? FALSE;
      $requestComponents = $body['components'] ?? [];
      // @todo This won't check if the alias is already used, potentially
      //   creating duplicated aliases.
      $requestPath = $body['path'] ?? NULL;
      $values = [
        'title' => $requestBodyTitle,
        'status' => $requestStatus,
        'components' => $requestComponents,
        'path' => $requestPath,
      ];
      if (isset($body['uuid'])) {
        $values['uuid'] = $body['uuid'];
        $existing = $this->entityTypeManager->getStorage($entity_type)->loadByProperties(['uuid' => $body['uuid']]);
        if ($existing !== []) {
          throw new ConflictHttpException(\sprintf('An entity with UUID "%s" already exists.', $body['uuid']));
        }
      }
      $new = $this->entityTypeManager->getStorage($entity_type)->create($values);
      $violations = $new->getTypedData()->validate();
      if ($violations->count() > 0) {
        if ($validation_errors_response = self::createJsonResponseFromViolationSets($violations)) {
          return $validation_errors_response;
        }
      }
      $new->save();
    }
    \assert($new instanceof ContentEntityInterface && $new instanceof EntityPublishedInterface);
    $data = $this->normalizeWithComponents($new, new CacheableMetadata());
    // This was the app client uses instead of `id`, added for BC compatibility.
    // @todo Remove this in a follow-up, along with the anyOf added to
    //   openapi.yml file
    $data['entity_id'] = $new->id();
    $data['entity_type'] = $entity_type;
    return new JsonResponse($data, RESPONSE::HTTP_CREATED);
  }

  /**
   * Deletes entity.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $canvas_page
   *   Entity to delete.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Response.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function delete(ContentEntityInterface $canvas_page): JsonResponse {
    $canvas_page->delete();
    return new JsonResponse(status: Response::HTTP_NO_CONTENT);
  }

  /**
   * Returns a list of content entities, with only high-level metadata.
   *
   * TRICKY: there are reasons Canvas has its own internal HTTP API rather than
   * using Drupal core's JSON:API. As soon as this method is updated to return
   * all fields instead of just high-level metadata, those reasons may start to
   * outweigh the downsides of adding a dependency on JSON:API.
   *
   * @see https://www.drupal.org/project/canvas/issues/3500052#comment-15966496
   */
  public function list(string $entity_type, Request $request): CacheableJsonResponse {
    if ($entity_type !== Page::ENTITY_TYPE_ID) {
      throw new BadRequestHttpException('Only the `canvas_page` content entity type is supported right now, will be generalized in a child issue of https://www.drupal.org/project/canvas/issues/3498525.');
    }
    $storage = $this->entityTypeManager->getStorage($entity_type);

    $query_cacheability = (new CacheableMetadata())
      ->addCacheContexts($storage->getEntityType()->getListCacheContexts())
      ->addCacheTags($storage->getEntityType()->getListCacheTags());

    // Prepare search term and determine if we're performing a search
    $search = $request->query->get('search', default: NULL);
    $query_cacheability->addCacheContexts(['url.query_args:search']);

    // Get the (ordered) list of content entity IDs to load, either:
    // - without a search term: paginate the newest content entities
    $offset = OffsetPage::DEFAULT_OFFSET;
    $limit = OffsetPage::MAX_SIZE;
    if ($search === NULL) {
      $query_cacheability->addCacheContexts(['url.query_args:page']);

      $page = OffsetPage::createFromRequest($request);
      $offset = $page->getOffset();
      $limit = $page->getLimit();

      $content_entity_type = $this->entityTypeManager->getDefinition($entity_type);
      \assert($content_entity_type instanceof ContentEntityTypeInterface);
      $revision_created_field_name = $content_entity_type->getRevisionMetadataKey('revision_created');
      // @todo Ensure this is one of the required characteristics in https://www.drupal.org/project/canvas/issues/3498525.
      \assert(\is_string($revision_created_field_name));
      $id_key = $content_entity_type->getKey('id');
      \assert(\is_string($id_key));

      $total_count = (int) $this->executeQueryInRenderContext(
        $storage->getQuery()->accessCheck(TRUE)->count(),
        $query_cacheability
      );

      $entity_query = $storage->getQuery()
        ->accessCheck(TRUE)
        ->sort($revision_created_field_name, direction: 'DESC')
        // Add a secondary sort by entity ID to ensure stable pagination when
        // multiple entities have the same revision_created timestamp. Without
        // this, the database may return items in different orders across
        // paginated requests, causing duplicates or missing items.
        ->sort($id_key)
        ->range($offset, $limit);

      $ids = $this->executeQueryInRenderContext($entity_query, $query_cacheability);
      \assert(\is_array($ids));
    }
    // - with a search term: get the N best matches using the entity reference
    //   selection plugin, get all auto-save matches, and combine both.
    //   Pagination is not supported for search, because it's not only
    //   searching stored pages with a query, but also including matches
    //   in auto-save results.
    else {
      \assert(\is_string($search));
      $search = trim($search);
      $ids = $this->filterAndMergeIds(
        // TRICKY: covered by the "list cacheability" at the top.
        $this->getMatchingStoredEntityIds($entity_type, $search),
        $this->getMatchingAutoSavedEntityIds($entity_type, $search, $query_cacheability)
      );
      $total_count = NULL;
    }

    /** @var \Drupal\Core\Entity\EntityPublishedInterface[] $content_entities */
    $content_entities = $storage->loadMultiple($ids);
    $data = [];
    foreach ($content_entities as $content_entity) {
      $data[] = $this->normalize($content_entity, $query_cacheability);
    }

    $response_body = ['data' => $data];
    if ($total_count !== NULL) {
      $response_body['meta'] = ['count' => $total_count];
      $response_body['links'] = $this->buildPaginationLinks($request, $offset, $limit, $total_count);
    }

    $json_response = new CacheableJsonResponse($response_body);
    $json_response->addCacheableDependency($query_cacheability);

    return $json_response;
  }

  /**
   * Builds JSON:API-style pagination links for a collection response.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request.
   * @param int $offset
   *   The current page offset.
   * @param int $limit
   *   The current page size.
   * @param int $total
   *   The total number of items in the collection.
   *
   * @return array<string, array{href: string}>
   *   An associative array of link objects keyed by relation type.
   *
   * @see https://jsonapi.org/format/#fetching-pagination
   */
  private function buildPaginationLinks(Request $request, int $offset, int $limit, int $total): array {
    $uri = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo();
    $existing_query = $request->query->all();
    unset($existing_query['page']);

    $make_link = static function (int $link_offset) use ($uri, $existing_query, $limit): array {
      $query = $existing_query + ['page' => ['offset' => $link_offset, 'limit' => $limit]];
      return ['href' => Url::fromUri($uri)->setOption('query', $query)->toString()];
    };

    $last_offset = $total > 0 ? (int) (floor(($total - 1) / $limit) * $limit) : 0;

    $links = ['self' => $make_link($offset)];

    if ($offset > 0) {
      $links['first'] = $make_link(0);
      $links['prev'] = $make_link(max(0, $offset - $limit));
    }
    if ($offset + $limit < $total) {
      $links['next'] = $make_link($offset + $limit);
      $links['last'] = $make_link($last_offset);
    }

    return $links;
  }

  /**
   * Normalizes content entity.
   *
   * @param \Drupal\Core\Entity\EntityPublishedInterface $content_entity
   *   The content entity to prepare data for.
   * @param \Drupal\Core\Cache\CacheableMetadata $url_cacheability
   *   The cacheability metadata object to add URL dependencies to.
   *
   * @return array
   *   An associative array containing the normalized entity.
   */
  private function normalize(EntityPublishedInterface $content_entity, CacheableMetadata $url_cacheability): array {
    $generated_url = $content_entity->toUrl()->toString(TRUE);

    $autoSaveData = $this->autoSaveManager->getAutoSaveEntity($content_entity);
    $autoSaveEntity = $autoSaveData->isEmpty() ? NULL : $autoSaveData->entity;

    $publishableAutoSaveEntity = ($autoSaveEntity instanceof EntityPublishedInterface)
      ? $autoSaveEntity
      : NULL;
    // Expose available entity operations. Pass both original and auto-save
    // entities to allow reverting unpublish/publish actions when auto-save has
    // opposite status.
    $linkCollection = $this->getEntityOperations($content_entity, $publishableAutoSaveEntity);

    // Determine the effective published status: use auto-save status if
    // available, otherwise use original.
    $effective_status = ($autoSaveEntity instanceof EntityPublishedInterface)
      ? $autoSaveEntity->isPublished()
      : $content_entity->isPublished();

    // @todo Dynamically use the entity 'path' key to determine which field is
    //   the path in https://drupal.org/i/3503446.
    $autoSavePath = NULL;
    if ($autoSaveEntity instanceof FieldableEntityInterface && $autoSaveEntity->hasField('path')) {
      $autoSavePath = $autoSaveEntity->get('path')->first()?->getValue()['alias'] ?? \sprintf('/%s', \ltrim($autoSaveEntity->toUrl()->getInternalPath(), '/'));
    }

    // Determine if there's an unsaved status change.
    // This happens when auto-save exists with a different published status
    // than the original entity.
    $has_unsaved_status_change = FALSE;
    if ($autoSaveEntity instanceof EntityPublishedInterface) {
      $has_unsaved_status_change = $autoSaveEntity->isPublished() !== $content_entity->isPublished();
    }

    $url_cacheability->addCacheableDependency($generated_url)
      ->addCacheableDependency($linkCollection)
      ->addCacheableDependency($autoSaveData);

    \assert($content_entity instanceof ContentEntityInterface);
    return [
      'id' => (int) $content_entity->id(),
      'uuid' => $content_entity->uuid(),
      'title' => $content_entity->label(),
      // Return the effective status (autosaved if exists, otherwise original).
      'status' => $effective_status,
      // Indicates if this is a new (draft) page that has never been published.
      'isNew' => AutoSaveManager::entityIsConsideredNew($content_entity),
      // Indicates if there's an unsaved status change
      // (unpublish/publish in auto-save).
      'hasUnsavedStatusChange' => $has_unsaved_status_change,
      // The processed path, which has gone through outbound path processors. It
      // may not be the same as the entity's canonical link template.
      'path' => $generated_url->getGeneratedUrl(),
      // The internal path, which has not been processed and is the entity's
      // canonical link template.
      'internalPath' => '/' . $content_entity->toUrl()->getInternalPath(),
      'autoSaveLabel' => $autoSaveEntity?->label(),
      'autoSavePath' => $autoSavePath,
      // @see https://jsonapi.org/format/#document-links
      'links' => $linkCollection->asArray(),
    ];
  }

  /**
   * Normalizes content entity including component tree fields.
   *
   * @param \Drupal\Core\Entity\EntityPublishedInterface&\Drupal\Core\Entity\ContentEntityInterface $content_entity
   *   The content entity to prepare data for.
   * @param \Drupal\Core\Cache\CacheableMetadata $url_cacheability
   *   The cacheability metadata object to add URL dependencies to.
   *
   * @return array
   *   An associative array containing the normalized entity.
   */
  private function normalizeWithComponents(EntityPublishedInterface&ContentEntityInterface $content_entity, CacheableMetadata $url_cacheability): array {
    $data = $this->normalize($content_entity, $url_cacheability);
    foreach ($content_entity->getFieldDefinitions() as $field_name => $field_definition) {
      if ($field_definition->getType() !== ComponentTreeItem::PLUGIN_ID) {
        continue;
      }
      $components = [];
      foreach ($content_entity->get($field_name) as $item) {
        \assert($item instanceof ComponentTreeItem);
        $values = \array_map(fn($property) => $property->getValue(), $item->getProperties(TRUE));

        // Ensure `parent_item` is not included, as that would be repetitive
        // information the consumer of this API can already access.
        // Component is also irrelevant, as will be empty.
        unset($values['parent_item'], $values['component']);
        // Ensure `inputs` is not a JSON string, but a structured value,
        // so API consumers receive a proper object rather than a JSON string.
        $values['inputs'] = $item->getInputs();
        $components[] = $values;
      }
      $field_access = $content_entity->get($field_name)->access('view', return_as_object: TRUE);
      if (!$field_access->isForbidden()) {
        $data[$field_name] = $components;
      }
      $url_cacheability->addCacheableDependency($field_access);
    }
    // Move links last for legibility.
    $links = $data['links'];
    unset($data['links']);
    $data['links'] = $links;
    return $data;
  }

  /**
   * Gets N first saved ("live") entity IDs matching the search term.
   *
   * @param string $entity_type_id
   *   The entity type ID.
   * @param string $search
   *   The (transliterated) search term to match against entities.
   *
   * @return array
   *   An array of entity IDs that match the search term.
   */
  private function getMatchingStoredEntityIds(string $entity_type_id, string $search): array {
    /** @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface $selection_handler */
    $selection_handler = $this->selectionManager->getInstance([
      'target_type' => $entity_type_id,
      'handler' => 'default',
    ]);
    \assert($selection_handler instanceof SelectionInterface);
    $matching_data = $selection_handler->getReferenceableEntities(
      $search,
      'CONTAINS',
      self::MAX_SEARCH_RESULTS
    );

    return \array_keys(NestedArray::mergeDeepArray($matching_data, TRUE));
  }

  /**
   * Gets N first auto-saved ("draft") entity IDs matching the search term.
   *
   * @param string $entity_type_id
   *   The entity type ID.
   * @param string $search
   *   The search term to match against entities.
   * @param \Drupal\Core\Cache\RefinableCacheableDependencyInterface $cacheability
   *   The cacheability of the given query, to be refined to match the
   *   refinements made to the query.
   *
   * @return array
   *   An array of entity IDs that match the search criteria.
   */
  private function getMatchingAutoSavedEntityIds(string $entity_type_id, string $search, RefinableCacheableDependencyInterface $cacheability): array {
    $cacheability->addCacheTags([AutoSaveManager::CACHE_TAG]);
    $auto_saved_entities_of_type = \array_filter($this->autoSaveManager->getAllAutoSaveList(TRUE), static fn (array $entry): bool => $entry['entity_type'] === $entity_type_id);

    // Transliterate the search term using the negotiated content language.
    $cacheability->addCacheContexts(['languages:' . LanguageInterface::TYPE_CONTENT]);
    $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId();
    $transliterated_search = $this->transliteration->transliterate(mb_strtolower($search), $langcode);

    // Check if the transliterated search term is contained by any of the auto-
    // saved entities of this type.
    $matching_unsaved_ids = [];
    foreach ($auto_saved_entities_of_type as ['entity' => $entity]) {
      \assert($entity instanceof EntityInterface);
      $transliterated_label = $this->transliteration->transliterate(mb_strtolower((string) $entity->label()), $langcode);
      if (str_contains($transliterated_label, $transliterated_search)) {
        $matching_unsaved_ids[] = $entity->id();
      }
    }

    return $matching_unsaved_ids;
  }

  /**
   * Filters and merges entity IDs based on search results.
   *
   * @param array $matching_ids
   *   The array of entity IDs that match the search term.
   * @param array $matching_unsaved_ids
   *   The array of unsaved entity IDs that match the search term.
   *
   * @return array
   *   The filtered and merged array of entity IDs.
   */
  private static function filterAndMergeIds(array $matching_ids, array $matching_unsaved_ids): array {
    // Sort by newest first (keys will be numeric IDs) and limit to max results
    $ids = array_unique(array_merge($matching_ids, $matching_unsaved_ids));
    arsort($ids);
    $ids = array_slice($ids, 0, self::MAX_SEARCH_RESULTS, TRUE);
    return $ids;
  }

  /**
   * Duplicates entity.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The entity to duplicate.
   *
   * @return \Drupal\Core\Entity\ContentEntityInterface
   *   Newly created entity.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  private function duplicate(ContentEntityInterface $entity): ContentEntityInterface {
    $duplicate = $entity->createDuplicate();

    // Get temp data of original entity.
    if ($entity = $this->autoSaveManager->getAutoSaveEntity($entity)->entity) {
      // Before merging temp data remove path value to avoid collision.
      // @todo Remove hardcoded field name when https://www.drupal.org/project/canvas/issues/3503446 lands.
      $duplicate = $entity->createDuplicate();
      \assert($duplicate instanceof ContentEntityInterface);
    }

    // Update title and status.
    $entity_type = $duplicate->getEntityType();
    $entity_key = $entity_type->getKey('label') ?? 'title';
    // @phpstan-ignore-next-line
    $duplicate->set($entity_key, $duplicate->label() . AutoSaveManager::ENTITY_DUPLICATE_SUFFIX);
    \assert($duplicate instanceof EntityPublishedInterface);
    $duplicate->setUnpublished();
    $duplicate->save();

    // Delete temp data for the duplicate, it should not have it at this point.
    // Everything is saved.
    $this->autoSaveManager->delete($duplicate);

    return $duplicate;
  }

  /**
   * Executes the query in a render context, to catch bubbled cacheability.
   *
   * @param \Drupal\Core\Entity\Query\QueryInterface $query
   *   The query to execute to get the return results.
   * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
   *   The value object to carry the query cacheability.
   *
   * @return array|int
   *   Returns IDs of entities, or a count when a count query is passed.
   *
   * @see \Drupal\jsonapi\Controller\EntityResource::executeQueryInRenderContext()
   */
  private function executeQueryInRenderContext(QueryInterface $query, CacheableMetadata $query_cacheability) : array|int {
    $context = new RenderContext();
    $results = $this->renderer->executeInRenderContext($context, function () use ($query) {
      return $query->execute();
    });
    if (!$context->isEmpty()) {
      $query_cacheability->addCacheableDependency($context->pop());
    }
    return $results;
  }

  public static function defaultTitle(EntityTypeInterface $entity_type): TranslatableMarkup {
    return new TranslatableMarkup('Untitled @singular_entity_type_label', ['@singular_entity_type_label' => $entity_type->getSingularLabel()]);
  }

  public function getEntityOperations(EntityPublishedInterface $content_entity, ?EntityPublishedInterface $autoSaveEntity = NULL): CanvasResourceLinkCollection {
    $links = new CanvasResourceLinkCollection([]);

    // Add auto-save entity as cache dependency if it exists, so cache
    // invalidates when auto-save changes.
    if ($autoSaveEntity) {
      $links->addCacheableDependency($autoSaveEntity);
    }

    // Helper to create forbidden access result with auto-save cache dependency.
    $createForbiddenAccess = function (string $reason) use ($autoSaveEntity): AccessResult {
      $access = AccessResult::forbidden($reason);
      if ($autoSaveEntity) {
        $access->addCacheableDependency($autoSaveEntity);
      }
      return $access;
    };

    // Link relation type => route name.
    $possible_operations = [
      CanvasUriDefinitions::LINK_REL_DELETE => ['route_name' => 'canvas.api.content.delete', 'op' => 'delete'],
      CanvasUriDefinitions::LINK_REL_EDIT => ['route_name' => 'canvas.boot.entity', 'op' => 'update'],
      // Setting the homepage is a staged configuration update, the UI will
      // call `canvas.api.config.post` but for the access check
      // use the content entity's access.
      // Conceptually, this is an operation on the content entity, so expose it
      // as a non-standard link operation.
      CanvasUriDefinitions::LINK_REL_SET_AS_HOMEPAGE => ['route_name' => 'canvas.boot.entity', 'op' => 'update'],
      CanvasUriDefinitions::LINK_REL_DUPLICATE => [
        'route_name' => 'canvas.api.content.create',
        'op' => 'create',
      ],
      CanvasUriDefinitions::LINK_REL_UNPUBLISH => [
        'route_name' => 'canvas.api.content.auto-save.patch',
        'op' => 'update',
      ],
      CanvasUriDefinitions::LINK_REL_PUBLISH => [
        'route_name' => 'canvas.api.content.auto-save.patch',
        'op' => 'update',
      ],
    ];
    // Determine which status-based action (unpublish/publish) should be shown.
    // Only one should be shown at a time, based on the effective status
    // and revert capability.
    $original_is_published = $content_entity->isPublished();
    $auto_save_is_published = $autoSaveEntity?->isPublished() ?? NULL;

    // Determine effective status: use auto-save if it exists,
    // otherwise use original.
    $effective_is_published = $auto_save_is_published ?? $original_is_published;

    // Check if this is a draft (never been published).
    \assert($content_entity instanceof ContentEntityInterface);
    $is_draft = AutoSaveManager::entityIsConsideredNew($content_entity);

    // Determine which actions to show based on state.
    // Drafts use the main Publish button, not unpublish/publish actions.
    if ($is_draft) {
      $should_show_unpublish = FALSE;
      $should_show_publish = FALSE;
    }
    // Auto-save has opposite status: show revert action.
    elseif ($auto_save_is_published !== NULL && $auto_save_is_published !== $original_is_published) {
      $should_show_unpublish = $auto_save_is_published;
      $should_show_publish = !$auto_save_is_published;
    }
    // No auto-save or auto-save matches original: show normal action.
    else {
      $should_show_unpublish = $effective_is_published;
      $should_show_publish = !$effective_is_published;
    }

    // Don't show unpublish link if this page is the homepage (current or
    // staged).
    $should_show_unpublish = $should_show_unpublish && !$this->homePageHelper->isHomepage($content_entity);

    foreach ($possible_operations as $link_rel => ['route_name' => $route_name, 'op' => $entity_operation]) {
      // Special handling for set as homepage operation: don't show for
      // unpublished pages (but allow for draft pages).
      if ($link_rel === CanvasUriDefinitions::LINK_REL_SET_AS_HOMEPAGE) {
        $access = (!$effective_is_published && !$is_draft)
          ? $createForbiddenAccess('Set as homepage action not available for unpublished pages.')
          : $content_entity->access(operation: $entity_operation, return_as_object: TRUE);
      }
      // Special handling for unpublish operation: only show if determined
      // above.
      elseif ($link_rel === CanvasUriDefinitions::LINK_REL_UNPUBLISH) {
        $access = !$should_show_unpublish
          ? $createForbiddenAccess('Unpublish action not available for this page state.')
          : $content_entity->access(operation: 'update', account: $this->currentUser, return_as_object: TRUE);
      }
      // Special handling for publish operation: only show if determined above.
      elseif ($link_rel === CanvasUriDefinitions::LINK_REL_PUBLISH) {
        $access = !$should_show_publish
          ? $createForbiddenAccess('Publish action not available for this page state.')
          : $content_entity->access(operation: 'update', account: $this->currentUser, return_as_object: TRUE);
      }
      else {
        $access = $content_entity->access(operation: $entity_operation, return_as_object: TRUE);
        if ($entity_operation === 'create') {
          $access = $this->entityTypeManager->getAccessControlHandler($content_entity->getEntityTypeId())
            ->createAccess(entity_bundle: $content_entity->bundle(), return_as_object: TRUE);
        }
      }
      \assert($access instanceof AccessResult);
      if ($access->isAllowed()) {
        $links = $links->withLink(
          $link_rel,
          new CanvasResourceLink($access, $this->getUrlFromRoute($content_entity, $route_name), $link_rel)
        );
      }
      else {
        $links->addCacheableDependency($access);
      }
    }
    return $links;
  }

  /**
   * Gets the url for an operation route given the content entity.
   *
   * Ideally, we would have standardized routes, and we wouldn't need a helper,
   * nor to compile the routes.
   * This might be achievable when we complete https://www.drupal.org/i/3498525.
   */
  private function getUrlFromRoute(EntityInterface $content_entity, string $route_name): Url {
    // @todo https://www.drupal.org/i/3498525 should standardize the
    //   route params. We need this helper for now.
    $match = fn($param) => match($param) {
      'entity_type' => $content_entity->getEntityTypeId(),
      'entity' => $content_entity->id(),
      $content_entity->getEntityTypeId() => $content_entity->id(),
      default => throw new \InvalidArgumentException('We cannot map this route parameter'),
    };
    $route = $this->routeProvider->getRouteByName($route_name);
    $route_parameters = $route->compile()->getVariables();
    $params = [];
    foreach ($route_parameters as $param) {
      $params[$param] = $match($param);
    }
    return Url::fromRoute($route_name, $params);
  }

}
