<?php

declare(strict_types=1);

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

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\Query\QueryInterface;
use Drupal\flowdrop_memory\Entity\MemoryRecordInterface;
use Drupal\flowdrop_memory\Plugin\MemoryBackend\EntityMemoryBackend;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;

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

  use ProphecyTrait;

  /**
   * The entity type manager mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private $entityTypeManager;

  /**
   * The entity storage mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private $storage;

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

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

    $this->entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
    $this->storage = $this->prophesize(EntityStorageInterface::class);

    $this->entityTypeManager->getStorage('flowdrop_memory_record')
      ->willReturn($this->storage->reveal());

    $this->backend = new EntityMemoryBackend(
      [],
      'entity',
      ['id' => 'entity'],
      $this->entityTypeManager->reveal(),
    );
  }

  /**
   * Creates a query mock that matches the given scope/scope_id/key conditions.
   */
  private function mockQuery(string $scope, string $scopeId, string $key, array $resultIds = []): void {
    $query = $this->prophesize(QueryInterface::class);
    $query->accessCheck(FALSE)->willReturn($query);
    $query->condition('scope', $scope)->willReturn($query);
    $query->condition('scope_id', $scopeId)->willReturn($query);
    $query->condition('memory_key', $key)->willReturn($query);
    $query->range(0, 1)->willReturn($query);
    $query->execute()->willReturn($resultIds);

    $this->storage->getQuery()->willReturn($query->reveal());
  }

  /**
   * Creates a scope-level query mock (no key condition).
   */
  private function mockScopeQuery(string $scope, string $scopeId, array $resultIds = []): void {
    $query = $this->prophesize(QueryInterface::class);
    $query->accessCheck(FALSE)->willReturn($query);
    $query->condition('scope', $scope)->willReturn($query);
    $query->condition('scope_id', $scopeId)->willReturn($query);
    $query->execute()->willReturn($resultIds);

    $this->storage->getQuery()->willReturn($query->reveal());
  }

  /**
   * Creates a memory record mock.
   */
  private function mockRecord(mixed $value = NULL, bool $expired = FALSE): MemoryRecordInterface {
    $record = $this->prophesize(MemoryRecordInterface::class);
    $record->getValue()->willReturn($value);
    $record->isExpired()->willReturn($expired);
    $record->setValue(Argument::any())->willReturn($record);
    $record->setExpiry(Argument::any())->willReturn($record);
    $record->save()->willReturn(NULL);
    $record->delete()->willReturn(NULL);
    $record->getMemoryKey()->willReturn('key');
    return $record->reveal();
  }

  /**
   * @covers ::get
   */
  public function testGetReturnsDefaultWhenRecordNotFound(): void {
    $this->mockQuery('global', '', 'missing', []);

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

  /**
   * @covers ::get
   */
  public function testGetReturnsNullDefaultWhenRecordNotFound(): void {
    $this->mockQuery('global', '', 'missing', []);

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

  /**
   * @covers ::get
   */
  public function testGetReturnsValueFromRecord(): void {
    $record = $this->mockRecord('stored-value');
    $this->mockQuery('global', '', 'mykey', [1]);
    $this->storage->load(1)->willReturn($record);

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

  /**
   * @covers ::get
   */
  public function testGetReturnsDefaultAndDeletesExpiredRecord(): void {
    $record = $this->prophesize(MemoryRecordInterface::class);
    $record->isExpired()->willReturn(TRUE);
    $record->delete()->shouldBeCalled();

    $this->mockQuery('global', '', 'expired-key', [1]);
    $this->storage->load(1)->willReturn($record->reveal());

    $this->assertSame('default', $this->backend->get('global', '', 'expired-key', 'default'));
  }

  /**
   * @covers ::get
   */
  public function testGetUsesReadCacheOnSecondCall(): void {
    $record = $this->mockRecord('cached-value');
    $this->mockQuery('global', '', 'cached', [1]);
    $this->storage->load(1)->willReturn($record);

    // First call populates read cache.
    $this->assertSame('cached-value', $this->backend->get('global', '', 'cached'));

    // Second call should use the read cache without querying again.
    // We can verify this by checking the value is still returned even though
    // we haven't set up a second query expectation.
    $this->assertSame('cached-value', $this->backend->get('global', '', 'cached'));
  }

  /**
   * @covers ::get
   */
  public function testGetReadCacheReturnsDefaultForNullCachedValue(): void {
    $this->mockQuery('global', '', 'nullkey', []);

    // First call caches NULL.
    $this->assertSame('fb', $this->backend->get('global', '', 'nullkey', 'fb'));

    // Second call: cached NULL should still return the provided default.
    $this->assertSame('other', $this->backend->get('global', '', 'nullkey', 'other'));
  }

  /**
   * @covers ::get
   */
  public function testGetReturnsDefaultWhenLoadReturnsNonRecord(): void {
    $this->mockQuery('global', '', 'badtype', [99]);
    // load() returns something that isn't MemoryRecordInterface.
    $this->storage->load(99)->willReturn(new \stdClass());

    $this->assertSame('fb', $this->backend->get('global', '', 'badtype', 'fb'));
  }

  /**
   * @covers ::set
   */
  public function testSetCreatesNewRecord(): void {
    $record = $this->prophesize(MemoryRecordInterface::class);
    $record->setValue('new-value')->willReturn($record)->shouldBeCalled();
    $record->setExpiry(NULL)->willReturn($record)->shouldBeCalled();
    $record->save()->shouldBeCalled();

    $this->mockQuery('pipeline', 'p-1', 'counter', []);
    $this->storage->create([
      'scope' => 'pipeline',
      'scope_id' => 'p-1',
      'memory_key' => 'counter',
    ])->willReturn($record->reveal());

    $this->backend->set('pipeline', 'p-1', 'counter', 'new-value');
  }

  /**
   * @covers ::set
   */
  public function testSetUpdatesExistingRecord(): void {
    $record = $this->prophesize(MemoryRecordInterface::class);
    $record->isExpired()->willReturn(FALSE);
    $record->setValue('updated')->willReturn($record)->shouldBeCalled();
    $record->setExpiry(NULL)->willReturn($record)->shouldBeCalled();
    $record->save()->shouldBeCalled();

    $this->mockQuery('global', '', 'existing', [5]);
    $this->storage->load(5)->willReturn($record->reveal());

    $this->backend->set('global', '', 'existing', 'updated');
  }

  /**
   * @covers ::set
   */
  public function testSetWithTtlCalculatesExpiry(): void {
    $record = $this->prophesize(MemoryRecordInterface::class);
    $record->setValue('val')->willReturn($record);
    $record->setExpiry(Argument::that(function ($expiry) {
      // The expiry should be approximately time() + 300.
      return $expiry >= time() + 299 && $expiry <= time() + 301;
    }))->willReturn($record)->shouldBeCalled();
    $record->save()->shouldBeCalled();

    $this->mockQuery('global', '', 'ttlkey', []);
    $this->storage->create(Argument::any())->willReturn($record->reveal());

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

  /**
   * @covers ::set
   */
  public function testSetWithNullTtlSetsNullExpiry(): void {
    $record = $this->prophesize(MemoryRecordInterface::class);
    $record->setValue('val')->willReturn($record);
    $record->setExpiry(NULL)->willReturn($record)->shouldBeCalled();
    $record->save()->shouldBeCalled();

    $this->mockQuery('global', '', 'permkey', []);
    $this->storage->create(Argument::any())->willReturn($record->reveal());

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

  /**
   * @covers ::set
   */
  public function testSetUpdatesReadCache(): void {
    $record = $this->prophesize(MemoryRecordInterface::class);
    $record->setValue('val')->willReturn($record);
    $record->setExpiry(NULL)->willReturn($record);
    $record->save()->willReturn(NULL);

    $this->mockQuery('global', '', 'cachekey', []);
    $this->storage->create(Argument::any())->willReturn($record->reveal());

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

    // get() should return 'val' from read cache without further queries.
    $this->assertSame('val', $this->backend->get('global', '', 'cachekey'));
  }

  /**
   * @covers ::delete
   */
  public function testDeleteRemovesExistingRecord(): void {
    $record = $this->prophesize(MemoryRecordInterface::class);
    $record->isExpired()->willReturn(FALSE);
    $record->delete()->shouldBeCalled();

    $this->mockQuery('global', '', 'toremove', [10]);
    $this->storage->load(10)->willReturn($record->reveal());

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

  /**
   * @covers ::delete
   */
  public function testDeleteIsNoOpWhenRecordNotFound(): void {
    $this->mockQuery('global', '', 'nonexistent', []);

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

  /**
   * @covers ::delete
   */
  public function testDeleteClearsReadCache(): void {
    // First populate the read cache.
    $record = $this->mockRecord('to-delete-val');
    $this->mockQuery('global', '', 'delcache', [1]);
    $this->storage->load(1)->willReturn($record);
    $this->backend->get('global', '', 'delcache');

    // Now delete and set up a fresh query mock.
    $recordProphecy = $this->prophesize(MemoryRecordInterface::class);
    $recordProphecy->isExpired()->willReturn(FALSE);
    $recordProphecy->delete()->shouldBeCalled();

    $query = $this->prophesize(QueryInterface::class);
    $query->accessCheck(FALSE)->willReturn($query);
    $query->condition('scope', 'global')->willReturn($query);
    $query->condition('scope_id', '')->willReturn($query);
    $query->condition('memory_key', 'delcache')->willReturn($query);
    $query->range(0, 1)->willReturn($query);
    $query->execute()->willReturn([1]);
    $this->storage->getQuery()->willReturn($query->reveal());
    $this->storage->load(1)->willReturn($recordProphecy->reveal());

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

    // After delete, get should hit the query again (read cache cleared).
    $emptyQuery = $this->prophesize(QueryInterface::class);
    $emptyQuery->accessCheck(FALSE)->willReturn($emptyQuery);
    $emptyQuery->condition('scope', 'global')->willReturn($emptyQuery);
    $emptyQuery->condition('scope_id', '')->willReturn($emptyQuery);
    $emptyQuery->condition('memory_key', 'delcache')->willReturn($emptyQuery);
    $emptyQuery->range(0, 1)->willReturn($emptyQuery);
    $emptyQuery->execute()->willReturn([]);
    $this->storage->getQuery()->willReturn($emptyQuery->reveal());

    $this->assertSame('gone', $this->backend->get('global', '', 'delcache', 'gone'));
  }

  /**
   * @covers ::has
   */
  public function testHasReturnsTrueWhenRecordExists(): void {
    $record = $this->mockRecord('exists');
    $this->mockQuery('global', '', 'present', [1]);
    $this->storage->load(1)->willReturn($record);

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

  /**
   * @covers ::has
   */
  public function testHasReturnsFalseWhenRecordNotFound(): void {
    $this->mockQuery('global', '', 'absent', []);

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

  /**
   * @covers ::has
   */
  public function testHasReturnsFalseWhenRecordExpired(): void {
    $record = $this->mockRecord('val', TRUE);
    $this->mockQuery('global', '', 'expired', [1]);
    $this->storage->load(1)->willReturn($record);

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

  /**
   * @covers ::has
   */
  public function testHasUsesReadCacheForExistingKey(): void {
    $record = $this->mockRecord('val');
    $this->mockQuery('global', '', 'cached', [1]);
    $this->storage->load(1)->willReturn($record);

    // Populate read cache.
    $this->backend->get('global', '', 'cached');

    // has() should use read cache.
    $this->assertTrue($this->backend->has('global', '', 'cached'));
  }

  /**
   * @covers ::has
   */
  public function testHasReturnsFalseForNullCachedValue(): void {
    $this->mockQuery('global', '', 'nullcached', []);

    // Populate cache with NULL.
    $this->backend->get('global', '', 'nullcached');

    // has() with NULL in cache should return FALSE.
    $this->assertFalse($this->backend->has('global', '', 'nullcached'));
  }

  /**
   * @covers ::keys
   */
  public function testKeysReturnsMemoryKeysForScope(): void {
    $record1 = $this->prophesize(MemoryRecordInterface::class);
    $record1->isExpired()->willReturn(FALSE);
    $record1->getMemoryKey()->willReturn('alpha');
    $record2 = $this->prophesize(MemoryRecordInterface::class);
    $record2->isExpired()->willReturn(FALSE);
    $record2->getMemoryKey()->willReturn('beta');

    $this->mockScopeQuery('global', '', [1, 2]);
    $this->storage->loadMultiple([1, 2])->willReturn([
      1 => $record1->reveal(),
      2 => $record2->reveal(),
    ]);

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

  /**
   * @covers ::keys
   */
  public function testKeysReturnsEmptyArrayWhenNoRecords(): void {
    $this->mockScopeQuery('global', '', []);

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

  /**
   * @covers ::keys
   */
  public function testKeysExcludesExpiredRecords(): void {
    $active = $this->prophesize(MemoryRecordInterface::class);
    $active->isExpired()->willReturn(FALSE);
    $active->getMemoryKey()->willReturn('active');
    $expired = $this->prophesize(MemoryRecordInterface::class);
    $expired->isExpired()->willReturn(TRUE);

    $this->mockScopeQuery('global', '', [1, 2]);
    $this->storage->loadMultiple([1, 2])->willReturn([
      1 => $active->reveal(),
      2 => $expired->reveal(),
    ]);

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

  /**
   * @covers ::clearScope
   */
  public function testClearScopeDeletesAllRecordsInScope(): void {
    $record1 = $this->prophesize(MemoryRecordInterface::class);
    $record2 = $this->prophesize(MemoryRecordInterface::class);

    $this->mockScopeQuery('pipeline', 'p-1', [1, 2]);
    $this->storage->loadMultiple([1, 2])->willReturn([
      1 => $record1->reveal(),
      2 => $record2->reveal(),
    ]);
    $this->storage->delete(Argument::type('array'))->shouldBeCalled();

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

  /**
   * @covers ::clearScope
   */
  public function testClearScopeIsNoOpWhenEmpty(): void {
    $this->mockScopeQuery('global', '', []);
    $this->storage->delete(Argument::any())->shouldNotBeCalled();

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

  /**
   * @covers ::clearScope
   */
  public function testClearScopeClearsReadCacheForScope(): void {
    // Populate read cache for a key.
    $record = $this->mockRecord('val');
    $this->mockQuery('pipeline', 'p-1', 'k1', [1]);
    $this->storage->load(1)->willReturn($record);
    $this->backend->get('pipeline', 'p-1', 'k1');

    // Clear scope.
    $this->mockScopeQuery('pipeline', 'p-1', [1]);
    $record2 = $this->prophesize(MemoryRecordInterface::class);
    $this->storage->loadMultiple([1])->willReturn([1 => $record2->reveal()]);
    $this->storage->delete(Argument::any())->willReturn(NULL);

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

    // After clearing, get should query again (cache cleared for scope).
    $emptyQuery = $this->prophesize(QueryInterface::class);
    $emptyQuery->accessCheck(FALSE)->willReturn($emptyQuery);
    $emptyQuery->condition('scope', 'pipeline')->willReturn($emptyQuery);
    $emptyQuery->condition('scope_id', 'p-1')->willReturn($emptyQuery);
    $emptyQuery->condition('memory_key', 'k1')->willReturn($emptyQuery);
    $emptyQuery->range(0, 1)->willReturn($emptyQuery);
    $emptyQuery->execute()->willReturn([]);
    $this->storage->getQuery()->willReturn($emptyQuery->reveal());

    $this->assertSame('default', $this->backend->get('pipeline', 'p-1', 'k1', 'default'));
  }

}
