<?php

declare(strict_types=1);

namespace Drupal\Tests\jsonapi\Kernel\EventSubscriber;

use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\entity_test\Entity\EntityTestComputedField;
use Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher;
use Drupal\jsonapi\JsonApiResource\ResourceObject;
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
use Drupal\KernelTests\KernelTestBase;
use Drupal\user\Entity\User;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;

/**
 * Tests Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher.
 *
 * @internal
 */
#[CoversClass(ResourceObjectNormalizationCacher::class)]
#[Group('jsonapi')]
#[RunTestsInSeparateProcesses]
class ResourceObjectNormalizerCacherTest extends KernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'entity_test',
    'file',
    'serialization',
    'text',
    'jsonapi',
    'user',
  ];

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

  /**
   * The JSON:API serializer.
   *
   * @var \Drupal\jsonapi\Serializer\Serializer
   */
  protected $serializer;

  /**
   * The object under test.
   *
   * @var \Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher
   */
  protected $cacher;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    // Add the entity schemas.
    $this->installEntitySchema('user');
    // Add the additional table schemas.
    $this->installSchema('user', ['users_data']);
    $this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository');
    $this->serializer = $this->container->get('jsonapi.serializer');
    $this->cacher = $this->container->get('jsonapi.normalization_cacher');
  }

  /**
   * Tests that link normalization cache information is not lost.
   *
   * @see https://www.drupal.org/project/drupal/issues/3077287
   */
  public function testLinkNormalizationCacheability(): void {
    $user = User::create([
      'name' => $this->randomMachineName(),
      'pass' => $this->randomString(),
    ]);
    $user->save();
    $resource_type = $this->resourceTypeRepository->get($user->getEntityTypeId(), $user->bundle());
    $resource_object = ResourceObject::createFromEntity($resource_type, $user);
    $cache_tag_to_invalidate = 'link_normalization';
    $normalized_links = $this->serializer
      ->normalize($resource_object->getLinks(), 'api_json')
      ->withCacheableDependency((new CacheableMetadata())->addCacheTags([$cache_tag_to_invalidate]));
    assert($normalized_links instanceof CacheableNormalization);
    $normalization_parts = [
      ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_BASE => [
        'type' => CacheableNormalization::permanent($resource_object->getTypeName()),
        'id' => CacheableNormalization::permanent($resource_object->getId()),
        'links' => $normalized_links,
      ],
      ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_FIELDS => [],
    ];
    $this->cacher->saveOnTerminate($resource_object, $normalization_parts);

    $http_kernel = $this->prophesize(HttpKernelInterface::class);
    $request = $this->prophesize(Request::class);
    $response = $this->prophesize(Response::class);
    $event = new TerminateEvent($http_kernel->reveal(), $request->reveal(), $response->reveal());
    $this->cacher->onTerminate($event);
    $this->assertNotFalse((bool) $this->cacher->get($resource_object));
    Cache::invalidateTags([$cache_tag_to_invalidate]);
    $this->assertFalse((bool) $this->cacher->get($resource_object));
  }

  /**
   * Tests that normalization max-age is correct.
   *
   * When max-age for a cached record is set the expiry is set accordingly. But
   * if the cached normalization is partially used in a later normalization the
   * max-age should be adjusted to a new timestamp.
   *
   * If we don't do this the expires of the cache record will be reset based on
   * the original max age. This leads to a drift in the expiry time of the
   * record.
   *
   * If a field tells the cache it should expire in exactly 1 hour, then if the
   * cached data is used 10 minutes later in another resource, that cache should
   * expire in 50 minutes and not reset to 60 minutes.
   */
  public function testMaxAgeCorrection(): void {
    $this->installEntitySchema('entity_test_computed_field');

    // Use EntityTestComputedField since ComputedTestCacheableStringItemList has
    // a max age of 800.
    $baseMaxAge = 800;
    $entity = EntityTestComputedField::create([]);
    $entity->save();
    $resource_type = $this->resourceTypeRepository->get($entity->getEntityTypeId(), $entity->bundle());
    $resource_object = ResourceObject::createFromEntity($resource_type, $entity);

    $resource_normalization = $this->serializer
      ->normalize($resource_object, 'api_json', ['account' => NULL]);
    $this->assertEquals($baseMaxAge, $resource_normalization->getCacheMaxAge());

    // Save the normalization to cache, this is done at TerminateEvent.
    $http_kernel = $this->prophesize(HttpKernelInterface::class);
    $request = $this->prophesize(Request::class);
    $response = $this->prophesize(Response::class);
    $event = new TerminateEvent($http_kernel->reveal(), $request->reveal(), $response->reveal());
    $this->cacher->onTerminate($event);

    // Change request time to 500 seconds later.
    $current_request = \Drupal::requestStack()->getCurrentRequest();
    $current_request->server->set('REQUEST_TIME', $current_request->server->get('REQUEST_TIME') + 500);
    $resource_normalization = $this->serializer
      ->normalize($resource_object, 'api_json', ['account' => NULL]);
    $this->assertEquals($baseMaxAge - 500, $resource_normalization->getCacheMaxAge(), 'Max age should be 300 since 500 seconds has passed');

    // Change request time to 300 seconds later (800 seconds later in total),
    // this is the last second the cache backend would return cached data. The
    // max-age at that time should be 0 as the request time is the same as the
    // expiration time of the cache entry.
    $current_request->server->set('REQUEST_TIME', $current_request->server->get('REQUEST_TIME') + 300);
    $resource_normalization = $this->serializer
      ->normalize($resource_object, 'api_json', ['account' => NULL]);
    $this->assertEquals(0, $resource_normalization->getCacheMaxAge(), 'Max age should be 0 since max-age has passed');

    // Change request time to 1 second later (801 seconds later in total). This
    // will generate a new cache entry because the one from above has now
    // expired. Thus, the max age of the normalization is back to 800.
    $current_request->server->set('REQUEST_TIME', $current_request->server->get('REQUEST_TIME') + 1);
    $resource_normalization = $this->serializer
      ->normalize($resource_object, 'api_json', ['account' => NULL]);
    $this->assertEquals(800, $resource_normalization->getCacheMaxAge(), 'Max age should be 800 since a new cache item has been created');

  }

  /**
   * Tests that when max-age is set to 0 the cacher does not cache the normalization.
   */
  public function testResourceObjectMaxAge0IsHandledByCacher(): void {
    $this->installEntitySchema('entity_test_computed_field');

    // Use EntityTestComputedField since ComputedTestCacheableStringItemList has
    // a max age of 800.
    $entity = EntityTestComputedField::create([]);
    $entity->save();
    $resource_type = $this->resourceTypeRepository->get($entity->getEntityTypeId(), $entity->bundle());
    $resource_object = ResourceObject::createFromEntity($resource_type, $entity);
    // Not yet cached, so this should return false.
    $this->assertFalse($this->cacher->get($resource_object));

    // Save the normalization to cache, this is done at TerminateEvent.
    $http_kernel = $this->prophesize(HttpKernelInterface::class);
    $request = $this->prophesize(Request::class);
    $response = $this->prophesize(Response::class);
    $event = new TerminateEvent($http_kernel->reveal(), $request->reveal(), $response->reveal());
    $this->cacher->saveOnTerminate($resource_object, ['base' => [], 'fields' => []]);
    $this->cacher->onTerminate($event);
    $this->assertNotFalse($this->cacher->get($resource_object));

    // Set max-age to 0 and see if we not skip caching.
    $entity->mergeCacheMaxAge(0);
    $resource_object = ResourceObject::createFromEntity($resource_type, $entity);
    $this->cacher->saveOnTerminate($resource_object, ['base' => [], 'fields' => []]);
    $this->cacher->onTerminate($event);

    // The cacher should not cache the normalization since max-age is 0.
    $this->assertFalse($this->cacher->get($resource_object));
  }

}
