<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_stategraph\Unit\Checkpointer;

use Prophecy\Argument;
use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop_stategraph\Checkpointer\MemoryCheckpointer;
use Drupal\flowdrop_stategraph\DTO\Checkpoint;
use Drupal\flowdrop_stategraph\DTO\GraphState;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;

/**
 * Tests the MemoryCheckpointer.
 *
 * @coversDefaultClass \Drupal\flowdrop_stategraph\Checkpointer\MemoryCheckpointer
 * @group flowdrop_stategraph
 */
class MemoryCheckpointerTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The checkpointer under test.
   *
   * @var \Drupal\flowdrop_stategraph\Checkpointer\MemoryCheckpointer
   */
  private MemoryCheckpointer $checkpointer;

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

    // Clear static storage between tests.
    // Note: For void methods, just stub without willReturn() or
    // shouldBeCalled().
    $logger = $this->prophesize(LoggerInterface::class);
    $logger->debug(Argument::cetera());
    $logger->info(Argument::cetera());

    $this->checkpointer = new MemoryCheckpointer($logger->reveal());
    $this->checkpointer->clear();
  }

  /**
   * {@inheritdoc}
   */
  protected function tearDown(): void {
    // Clean up after each test.
    $this->checkpointer->clear();
    parent::tearDown();
  }

  /**
   * Creates a test checkpoint.
   *
   * @param string $id
   *   The checkpoint ID.
   * @param string $threadId
   *   The thread ID.
   * @param int $createdAt
   *   The creation timestamp.
   *
   * @return \Drupal\flowdrop_stategraph\DTO\Checkpoint
   *   A test checkpoint.
   */
  private function createTestCheckpoint(
    string $id,
    string $threadId,
    int $createdAt = 0,
  ): Checkpoint {
    $state = new GraphState(threadId: $threadId);
    return new Checkpoint(
      id: $id,
      threadId: $threadId,
      state: $state,
      nodeId: "test_node",
      createdAt: $createdAt > 0 ? $createdAt : time(),
    );
  }

  /**
   * Tests put and get checkpoint.
   *
   * @covers ::put
   * @covers ::get
   */
  public function testPutAndGet(): void {
    $checkpoint = $this->createTestCheckpoint("cp_test1", "thread_1");

    $returnedId = $this->checkpointer->put($checkpoint);

    $this->assertEquals("cp_test1", $returnedId);

    $retrieved = $this->checkpointer->get("cp_test1");

    $this->assertNotNull($retrieved);
    $this->assertEquals("cp_test1", $retrieved->id);
    $this->assertEquals("thread_1", $retrieved->threadId);
  }

  /**
   * Tests get returns null for unknown ID.
   *
   * @covers ::get
   */
  public function testGetReturnsNullForUnknown(): void {
    $result = $this->checkpointer->get("nonexistent_id");

    $this->assertNull($result);
  }

  /**
   * Tests list retrieves checkpoints for a thread.
   *
   * @covers ::list
   */
  public function testListByThread(): void {
    // Create checkpoints for different threads.
    $cp1 = $this->createTestCheckpoint("cp_1", "thread_a", time() - 100);
    $cp2 = $this->createTestCheckpoint("cp_2", "thread_a", time() - 50);
    $cp3 = $this->createTestCheckpoint("cp_3", "thread_b", time());

    $this->checkpointer->put($cp1);
    $this->checkpointer->put($cp2);
    $this->checkpointer->put($cp3);

    $threadACheckpoints = $this->checkpointer->list("thread_a");

    $this->assertCount(2, $threadACheckpoints);
    // All should be from thread_a.
    foreach ($threadACheckpoints as $cp) {
      $this->assertEquals("thread_a", $cp->threadId);
    }
  }

  /**
   * Tests list orders by creation time (newest first).
   *
   * @covers ::list
   */
  public function testListOrdersByCreationTime(): void {
    $now = time();

    $cp1 = $this->createTestCheckpoint("cp_oldest", "thread_1", $now - 100);
    $cp2 = $this->createTestCheckpoint("cp_middle", "thread_1", $now - 50);
    $cp3 = $this->createTestCheckpoint("cp_newest", "thread_1", $now);

    // Add in random order.
    $this->checkpointer->put($cp2);
    $this->checkpointer->put($cp1);
    $this->checkpointer->put($cp3);

    $checkpoints = $this->checkpointer->list("thread_1");

    // Should be ordered newest first.
    $this->assertEquals("cp_newest", $checkpoints[0]->id);
    $this->assertEquals("cp_middle", $checkpoints[1]->id);
    $this->assertEquals("cp_oldest", $checkpoints[2]->id);
  }

  /**
   * Tests list applies limit parameter.
   *
   * @covers ::list
   */
  public function testListAppliesLimit(): void {
    $now = time();

    // Create 5 checkpoints.
    for ($i = 0; $i < 5; $i++) {
      $cp = $this->createTestCheckpoint("cp_{$i}", "thread_1", $now - $i);
      $this->checkpointer->put($cp);
    }

    // Request only 2.
    $checkpoints = $this->checkpointer->list("thread_1", 2);

    $this->assertCount(2, $checkpoints);
  }

  /**
   * Tests list returns empty for unknown thread.
   *
   * @covers ::list
   */
  public function testListReturnsEmptyForUnknownThread(): void {
    $checkpoints = $this->checkpointer->list("nonexistent_thread");

    $this->assertEquals([], $checkpoints);
  }

  /**
   * Tests getLatest returns most recent checkpoint.
   *
   * @covers ::getLatest
   */
  public function testGetLatest(): void {
    $now = time();

    $cp1 = $this->createTestCheckpoint("cp_old", "thread_1", $now - 100);
    $cp2 = $this->createTestCheckpoint("cp_new", "thread_1", $now);

    $this->checkpointer->put($cp1);
    $this->checkpointer->put($cp2);

    $latest = $this->checkpointer->getLatest("thread_1");

    $this->assertNotNull($latest);
    $this->assertEquals("cp_new", $latest->id);
  }

  /**
   * Tests getLatest returns null for empty thread.
   *
   * @covers ::getLatest
   */
  public function testGetLatestReturnsNullForEmptyThread(): void {
    $result = $this->checkpointer->getLatest("nonexistent_thread");

    $this->assertNull($result);
  }

  /**
   * Tests delete removes checkpoint.
   *
   * @covers ::delete
   */
  public function testDelete(): void {
    $checkpoint = $this->createTestCheckpoint("cp_delete", "thread_1");
    $this->checkpointer->put($checkpoint);

    // Verify it exists.
    $this->assertNotNull($this->checkpointer->get("cp_delete"));

    $result = $this->checkpointer->delete("cp_delete");

    $this->assertTrue($result);
    $this->assertNull($this->checkpointer->get("cp_delete"));
  }

  /**
   * Tests delete returns false for unknown ID.
   *
   * @covers ::delete
   */
  public function testDeleteReturnsFalseForUnknown(): void {
    $result = $this->checkpointer->delete("nonexistent_id");

    $this->assertFalse($result);
  }

  /**
   * Tests delete removes from thread index.
   *
   * @covers ::delete
   */
  public function testDeleteRemovesFromThreadIndex(): void {
    $cp1 = $this->createTestCheckpoint("cp_1", "thread_1");
    $cp2 = $this->createTestCheckpoint("cp_2", "thread_1");

    $this->checkpointer->put($cp1);
    $this->checkpointer->put($cp2);

    // Delete one.
    $this->checkpointer->delete("cp_1");

    // List should only have one.
    $checkpoints = $this->checkpointer->list("thread_1");
    $this->assertCount(1, $checkpoints);
    $this->assertEquals("cp_2", $checkpoints[0]->id);
  }

  /**
   * Tests deleteThread removes all checkpoints for a thread.
   *
   * @covers ::deleteThread
   */
  public function testDeleteThread(): void {
    // Create checkpoints for two threads.
    $cp1 = $this->createTestCheckpoint("cp_a1", "thread_a");
    $cp2 = $this->createTestCheckpoint("cp_a2", "thread_a");
    $cp3 = $this->createTestCheckpoint("cp_b1", "thread_b");

    $this->checkpointer->put($cp1);
    $this->checkpointer->put($cp2);
    $this->checkpointer->put($cp3);

    $deletedCount = $this->checkpointer->deleteThread("thread_a");

    $this->assertEquals(2, $deletedCount);

    // Thread A should be empty.
    $this->assertEquals([], $this->checkpointer->list("thread_a"));

    // Thread B should still have its checkpoint.
    $threadBCheckpoints = $this->checkpointer->list("thread_b");
    $this->assertCount(1, $threadBCheckpoints);
  }

  /**
   * Tests deleteThread returns zero for unknown thread.
   *
   * @covers ::deleteThread
   */
  public function testDeleteThreadReturnsZeroForUnknown(): void {
    $result = $this->checkpointer->deleteThread("nonexistent_thread");

    $this->assertEquals(0, $result);
  }

  /**
   * Tests clear removes all checkpoints.
   *
   * @covers ::clear
   */
  public function testClear(): void {
    // Create checkpoints in multiple threads.
    $cp1 = $this->createTestCheckpoint("cp_1", "thread_a");
    $cp2 = $this->createTestCheckpoint("cp_2", "thread_b");
    $cp3 = $this->createTestCheckpoint("cp_3", "thread_c");

    $this->checkpointer->put($cp1);
    $this->checkpointer->put($cp2);
    $this->checkpointer->put($cp3);

    $clearedCount = $this->checkpointer->clear();

    $this->assertEquals(3, $clearedCount);

    // All threads should be empty.
    $this->assertEquals([], $this->checkpointer->list("thread_a"));
    $this->assertEquals([], $this->checkpointer->list("thread_b"));
    $this->assertEquals([], $this->checkpointer->list("thread_c"));
  }

  /**
   * Tests clear returns zero when already empty.
   *
   * @covers ::clear
   */
  public function testClearReturnsZeroWhenEmpty(): void {
    $result = $this->checkpointer->clear();

    $this->assertEquals(0, $result);
  }

  /**
   * Tests put overwrites existing checkpoint with same ID.
   *
   * @covers ::put
   */
  public function testPutOverwritesExisting(): void {
    $original = $this->createTestCheckpoint("cp_same", "thread_1");
    $this->checkpointer->put($original);

    // Create new checkpoint with same ID but different data.
    $updated = new Checkpoint(
      id: "cp_same",
      threadId: "thread_1",
      state: new GraphState(threadId: "thread_1", data: ["updated" => TRUE]),
      nodeId: "updated_node",
      createdAt: time(),
    );

    $this->checkpointer->put($updated);

    $retrieved = $this->checkpointer->get("cp_same");
    $this->assertEquals("updated_node", $retrieved->nodeId);
    $this->assertTrue($retrieved->state->data["updated"]);
  }

  /**
   * Tests that checkpoints maintain data integrity.
   *
   * @covers ::put
   * @covers ::get
   */
  public function testDataIntegrity(): void {
    $state = new GraphState(
      threadId: "thread_1",
      data: ["complex" => ["nested" => "value"]],
      iterationCount: 5,
    );

    $checkpoint = new Checkpoint(
      id: "cp_integrity",
      threadId: "thread_1",
      state: $state,
      nodeId: "complex_node",
      createdAt: time(),
      parentCheckpointId: "cp_parent",
      metadata: ["test" => "metadata"],
    );

    $this->checkpointer->put($checkpoint);
    $retrieved = $this->checkpointer->get("cp_integrity");

    $this->assertEquals($checkpoint->id, $retrieved->id);
    $this->assertEquals($checkpoint->threadId, $retrieved->threadId);
    $this->assertEquals($checkpoint->nodeId, $retrieved->nodeId);
    $this->assertEquals($checkpoint->parentCheckpointId, $retrieved->parentCheckpointId);
    $this->assertEquals($checkpoint->metadata, $retrieved->metadata);
    $this->assertEquals($checkpoint->state->data, $retrieved->state->data);
    $this->assertEquals($checkpoint->state->iterationCount, $retrieved->state->iterationCount);
  }

  /**
   * Tests concurrent operations on same thread.
   *
   * @covers ::put
   * @covers ::list
   * @covers ::delete
   */
  public function testConcurrentOperationsOnSameThread(): void {
    // Add multiple checkpoints.
    for ($i = 1; $i <= 10; $i++) {
      $cp = $this->createTestCheckpoint("cp_{$i}", "thread_1", time() - (10 - $i));
      $this->checkpointer->put($cp);
    }

    // Verify all added.
    $this->assertCount(10, $this->checkpointer->list("thread_1"));

    // Delete some.
    $this->checkpointer->delete("cp_3");
    $this->checkpointer->delete("cp_7");

    // Verify count.
    $this->assertCount(8, $this->checkpointer->list("thread_1"));

    // Verify specific ones gone.
    $this->assertNull($this->checkpointer->get("cp_3"));
    $this->assertNull($this->checkpointer->get("cp_7"));

    // Verify others still present.
    $this->assertNotNull($this->checkpointer->get("cp_1"));
    $this->assertNotNull($this->checkpointer->get("cp_10"));
  }

}
