<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_memory\Unit\Plugin\MemoryBackend;

use Prophecy\Argument;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\flowdrop_memory\Plugin\MemoryBackend\CachedMemoryBackend;
use Drupal\Tests\UnitTestCase;
use Prophecy\PhpUnit\ProphecyTrait;

/**
 * Tests the CachedMemoryBackend plugin.
 *
 * @coversDefaultClass \Drupal\flowdrop_memory\Plugin\MemoryBackend\CachedMemoryBackend
 * @group flowdrop_memory
 */
class CachedMemoryBackendTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The cache backend mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private $cache;

  /**
   * The backend under test.
   */
  private CachedMemoryBackend $backend;

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

    $this->cache = $this->prophesize(CacheBackendInterface::class);

    $this->backend = new CachedMemoryBackend([], 'cached', ['id' => 'cached']);
    // Inject the cache backend via reflection since create() depends on
    // the container which we don't have in unit tests.
    $ref = new \ReflectionProperty(CachedMemoryBackend::class, 'cache');
    $ref->setAccessible(TRUE);
    $ref->setValue($this->backend, $this->cache->reveal());
  }

  /**
   * Helper to create a cache item object.
   */
  private function cacheItem(mixed $data): object {
    return (object) ['data' => $data];
  }

  /**
   * @covers ::get
   */
  public function testGetReturnsValueFromCache(): void {
    $this->cache->get('flowdrop_memory:global:__global__:mykey')
      ->willReturn($this->cacheItem('stored'));

    $this->assertSame('stored', $this->backend->get('global', '', 'mykey'));
  }

  /**
   * @covers ::get
   */
  public function testGetReturnsDefaultWhenCacheMiss(): void {
    $this->cache->get('flowdrop_memory:global:__global__:missing')
      ->willReturn(FALSE);

    $this->assertSame('fallback', $this->backend->get('global', '', 'missing', 'fallback'));
  }

  /**
   * @covers ::get
   */
  public function testGetReturnsNullDefaultWhenCacheMiss(): void {
    $this->cache->get('flowdrop_memory:global:__global__:missing')
      ->willReturn(FALSE);

    $this->assertNull($this->backend->get('global', '', 'missing'));
  }

  /**
   * @covers ::get
   */
  public function testGetWithScopeId(): void {
    $this->cache->get('flowdrop_memory:workflow:wf-123:counter')
      ->willReturn($this->cacheItem(42));

    $this->assertSame(42, $this->backend->get('workflow', 'wf-123', 'counter'));
  }

  /**
   * @covers ::get
   */
  public function testGetReturnsComplexValue(): void {
    $value = ['nested' => ['a' => 1, 'b' => [TRUE, NULL]]];
    $this->cache->get('flowdrop_memory:global:__global__:complex')
      ->willReturn($this->cacheItem($value));

    $this->assertSame($value, $this->backend->get('global', '', 'complex'));
  }

  /**
   * @covers ::set
   */
  public function testSetStoresValueWithPermanentExpiry(): void {
    $this->cache->set(
      'flowdrop_memory:global:__global__:key',
      'value',
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    // Index maintenance: get existing index, add new key.
    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn(FALSE);
    $this->cache->set(
      'flowdrop_memory:global:__global__:__index__',
      ['key'],
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    $this->backend->set('global', '', 'key', 'value');
  }

  /**
   * @covers ::set
   */
  public function testSetStoresValueWithTtl(): void {
    $expectedExpiry = time() + 300;
    $this->cache->set(
      'flowdrop_memory:global:__global__:ttlkey',
      'val',
      // Allow +-1 second of time drift.
      Argument::that(fn($e) => $e >= $expectedExpiry - 1 && $e <= $expectedExpiry + 1),
    )->shouldBeCalled();

    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn(FALSE);
    $this->cache->set(
      'flowdrop_memory:global:__global__:__index__',
      ['ttlkey'],
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    $this->backend->set('global', '', 'ttlkey', 'val', 300);
  }

  /**
   * @covers ::set
   */
  public function testSetWithNullTtlIsPermanent(): void {
    $this->cache->set(
      'flowdrop_memory:global:__global__:permkey',
      'val',
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn(FALSE);
    $this->cache->set(
      'flowdrop_memory:global:__global__:__index__',
      ['permkey'],
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    $this->backend->set('global', '', 'permkey', 'val', NULL);
  }

  /**
   * @covers ::set
   */
  public function testSetDoesNotDuplicateKeyInIndex(): void {
    $this->cache->set(
      'flowdrop_memory:global:__global__:existing',
      'val',
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    // Index already contains this key.
    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn($this->cacheItem(['existing', 'other']));

    // Index should NOT be updated since key already exists.
    // (No set call for the index key with the existing key duplicated.)
    $this->backend->set('global', '', 'existing', 'val');
  }

  /**
   * @covers ::set
   */
  public function testSetAppendsNewKeyToExistingIndex(): void {
    $this->cache->set(
      'flowdrop_memory:global:__global__:newkey',
      'val',
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn($this->cacheItem(['existing']));
    $this->cache->set(
      'flowdrop_memory:global:__global__:__index__',
      ['existing', 'newkey'],
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    $this->backend->set('global', '', 'newkey', 'val');
  }

  /**
   * @covers ::delete
   */
  public function testDeleteRemovesCacheEntry(): void {
    $this->cache->delete('flowdrop_memory:global:__global__:key')
      ->shouldBeCalled();

    // Index maintenance.
    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn($this->cacheItem(['key', 'other']));
    $this->cache->set(
      'flowdrop_memory:global:__global__:__index__',
      ['other'],
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    $this->backend->delete('global', '', 'key');
  }

  /**
   * @covers ::delete
   */
  public function testDeleteWhenKeyNotInIndex(): void {
    $this->cache->delete('flowdrop_memory:global:__global__:noindex')
      ->shouldBeCalled();

    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn(FALSE);

    // Should not throw.
    $this->backend->delete('global', '', 'noindex');
  }

  /**
   * @covers ::has
   */
  public function testHasReturnsTrueOnCacheHit(): void {
    $this->cache->get('flowdrop_memory:global:__global__:present')
      ->willReturn($this->cacheItem('val'));

    $this->assertTrue($this->backend->has('global', '', 'present'));
  }

  /**
   * @covers ::has
   */
  public function testHasReturnsFalseOnCacheMiss(): void {
    $this->cache->get('flowdrop_memory:global:__global__:missing')
      ->willReturn(FALSE);

    $this->assertFalse($this->backend->has('global', '', 'missing'));
  }

  /**
   * @covers ::keys
   */
  public function testKeysReturnsIndexContents(): void {
    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn($this->cacheItem(['alpha', 'beta']));

    $this->assertSame(['alpha', 'beta'], $this->backend->keys('global', ''));
  }

  /**
   * @covers ::keys
   */
  public function testKeysReturnsEmptyArrayWhenNoIndex(): void {
    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn(FALSE);

    $this->assertSame([], $this->backend->keys('global', ''));
  }

  /**
   * @covers ::keys
   */
  public function testKeysWithScopeId(): void {
    $this->cache->get('flowdrop_memory:pipeline:p-1:__index__')
      ->willReturn($this->cacheItem(['counter', 'state']));

    $this->assertSame(['counter', 'state'], $this->backend->keys('pipeline', 'p-1'));
  }

  /**
   * @covers ::clearScope
   */
  public function testClearScopeDeletesAllKeysAndIndex(): void {
    // Return keys from index.
    $this->cache->get('flowdrop_memory:pipeline:p-1:__index__')
      ->willReturn($this->cacheItem(['a', 'b']));

    // Should delete each key.
    $this->cache->delete('flowdrop_memory:pipeline:p-1:a')->shouldBeCalled();
    $this->cache->delete('flowdrop_memory:pipeline:p-1:b')->shouldBeCalled();
    // Should delete the index itself.
    $this->cache->delete('flowdrop_memory:pipeline:p-1:__index__')->shouldBeCalled();

    $this->backend->clearScope('pipeline', 'p-1');
  }

  /**
   * @covers ::clearScope
   */
  public function testClearScopeIsNoOpWhenEmpty(): void {
    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn(FALSE);

    // Only the index deletion is called.
    $this->cache->delete('flowdrop_memory:global:__global__:__index__')
      ->shouldBeCalled();

    $this->backend->clearScope('global', '');
  }

  /**
   * @covers ::set
   * @covers ::get
   */
  public function testSetWithZeroTtlIsPermanent(): void {
    $this->cache->set(
      'flowdrop_memory:global:__global__:zerokey',
      'val',
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    $this->cache->get('flowdrop_memory:global:__global__:__index__')
      ->willReturn(FALSE);
    $this->cache->set(
      'flowdrop_memory:global:__global__:__index__',
      ['zerokey'],
      CacheBackendInterface::CACHE_PERMANENT,
    )->shouldBeCalled();

    $this->backend->set('global', '', 'zerokey', 'val', 0);
  }

}
