<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_runtime\Unit\Service\WorkflowState;

use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop_orchestration\DTO\Snapshot\NodeSnapshot;
use Drupal\flowdrop_orchestration\DTO\Snapshot\WorkflowSnapshot;
use Drupal\flowdrop_runtime\Service\WorkflowState\EntitySnapshotStorage;
use Drupal\flowdrop_runtime\Service\WorkflowState\WorkflowStateManager;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;

/**
 * Tests the WorkflowStateManager service.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\WorkflowState\WorkflowStateManager
 * @group flowdrop_runtime
 */
class WorkflowStateManagerTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The workflow state manager under test.
   *
   * @var \Drupal\flowdrop_runtime\Service\WorkflowState\WorkflowStateManager
   */
  private WorkflowStateManager $stateManager;

  /**
   * Mock logger.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<\Psr\Log\LoggerInterface>
   */
  private $logger;

  /**
   * Mock entity storage.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<\Drupal\flowdrop_runtime\Service\WorkflowState\EntitySnapshotStorage>
   */
  private $entityStorage;

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

    $this->logger = $this->prophesize(LoggerInterface::class);
    $this->entityStorage = $this->prophesize(EntitySnapshotStorage::class);

    // Allow any logging calls (void return type, no willReturn).
    $this->logger->debug(Argument::any(), Argument::any());
    $this->logger->info(Argument::any(), Argument::any());
    $this->logger->error(Argument::any(), Argument::any());

    $this->stateManager = new WorkflowStateManager(
      $this->logger->reveal(),
      $this->entityStorage->reveal()
    );
  }

  /**
   * @covers ::createSnapshot
   */
  public function testCreateSnapshot(): void {
    $snapshot = $this->stateManager->createSnapshot(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
      initialInput: ["key" => "value"],
      threadId: "thread_001",
      metadata: ["source" => "test"],
    );

    $this->assertEquals("test_workflow", $snapshot->workflowId);
    $this->assertEquals("abc123", $snapshot->workflowVersion);
    $this->assertEquals(["key" => "value"], $snapshot->initialInput);
    $this->assertEquals("thread_001", $snapshot->threadId);
    $this->assertArrayHasKey("source", $snapshot->metadata);
    $this->assertEquals("pending", $snapshot->status);
    $this->assertNotEmpty($snapshot->executionId);
  }

  /**
   * @covers ::createSnapshot
   */
  public function testCreateSnapshotGeneratesThreadIdIfNotProvided(): void {
    $snapshot = $this->stateManager->createSnapshot(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );

    $this->assertNotEmpty($snapshot->threadId);
    $this->assertStringStartsWith("thread_", $snapshot->threadId);
  }

  /**
   * @covers ::save
   */
  public function testSavePersistsToEntityStorage(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
      initialInput: ["data" => "test"],
    );

    // Expect the entity storage to receive a save call.
    $this->entityStorage->save(Argument::type(WorkflowSnapshot::class))
      ->shouldBeCalled();

    $savedSnapshot = $this->stateManager->save($snapshot);

    $this->assertEquals($snapshot->executionId, $savedSnapshot->executionId);
    $this->assertGreaterThan(0, $savedSnapshot->updatedAt);
  }

  /**
   * @covers ::save
   */
  public function testSaveUpdatesTimestamp(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );

    $this->entityStorage->save(Argument::any())->shouldBeCalled();

    $beforeSave = time();
    $savedSnapshot = $this->stateManager->save($snapshot);
    $afterSave = time();

    $this->assertGreaterThanOrEqual($beforeSave, $savedSnapshot->updatedAt);
    $this->assertLessThanOrEqual($afterSave, $savedSnapshot->updatedAt);
  }

  /**
   * @covers ::getSnapshot
   */
  public function testGetSnapshotReturnsNullForNonexistent(): void {
    $result = $this->stateManager->getSnapshot("nonexistent_exec_id");
    $this->assertNull($result);
  }

  /**
   * @covers ::getSnapshot
   */
  public function testGetSnapshotReturnsSavedSnapshot(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $retrieved = $this->stateManager->getSnapshot($snapshot->executionId);

    $this->assertNotNull($retrieved);
    $this->assertEquals($snapshot->executionId, $retrieved->executionId);
  }

  /**
   * @covers ::initializeNodesFromWorkflow
   */
  public function testInitializeNodesFromWorkflow(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );

    $workflowDefinition = [
      "nodes" => [
        ["id" => "node_1", "data" => ["type" => "input"]],
        ["id" => "node_2", "data" => ["type" => "process"]],
        ["id" => "node_3", "data" => ["type" => "output"]],
      ],
      "edges" => [],
    ];

    $initialized = $this->stateManager->initializeNodesFromWorkflow(
      $snapshot,
      $workflowDefinition
    );

    $this->assertCount(3, $initialized->nodeStates);
    $this->assertArrayHasKey("node_1", $initialized->nodeStates);
    $this->assertArrayHasKey("node_2", $initialized->nodeStates);
    $this->assertArrayHasKey("node_3", $initialized->nodeStates);

    // All nodes should be pending.
    foreach ($initialized->nodeStates as $nodeState) {
      $this->assertEquals(NodeSnapshot::STATUS_PENDING, $nodeState->status);
    }
  }

  /**
   * @covers ::setNodeCompleted
   */
  public function testSetNodeCompleted(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );
    $snapshot = $snapshot->withInitializedNodes([
      ["id" => "node_1"],
      ["id" => "node_2"],
    ]);

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $updated = $this->stateManager->setNodeCompleted(
      executionId: $snapshot->executionId,
      nodeId: "node_1",
      output: ["result" => "success"],
      injected: FALSE,
    );

    $nodeState = $updated->getNodeState("node_1");
    $this->assertNotNull($nodeState);
    $this->assertEquals(NodeSnapshot::STATUS_COMPLETED, $nodeState->status);
    $this->assertEquals(["result" => "success"], $nodeState->output);
    $this->assertFalse($nodeState->injected);
  }

  /**
   * @covers ::setNodeCompleted
   */
  public function testSetNodeCompletedWithInjected(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );
    $snapshot = $snapshot->withInitializedNodes([["id" => "node_1"]]);

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $updated = $this->stateManager->setNodeCompleted(
      executionId: $snapshot->executionId,
      nodeId: "node_1",
      output: ["data" => "preloaded"],
      injected: TRUE,
    );

    $nodeState = $updated->getNodeState("node_1");
    $this->assertTrue($nodeState->injected);
  }

  /**
   * @covers ::setNodeFailed
   */
  public function testSetNodeFailed(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );
    $snapshot = $snapshot->withInitializedNodes([["id" => "node_1"]]);

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $updated = $this->stateManager->setNodeFailed(
      executionId: $snapshot->executionId,
      nodeId: "node_1",
      error: "Something went wrong",
    );

    $nodeState = $updated->getNodeState("node_1");
    $this->assertEquals(NodeSnapshot::STATUS_FAILED, $nodeState->status);
    $this->assertEquals("Something went wrong", $nodeState->error);
  }

  /**
   * @covers ::setNodeRunning
   */
  public function testSetNodeRunning(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );
    $snapshot = $snapshot->withInitializedNodes([["id" => "node_1"]]);

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $updated = $this->stateManager->setNodeRunning(
      executionId: $snapshot->executionId,
      nodeId: "node_1",
    );

    $nodeState = $updated->getNodeState("node_1");
    $this->assertEquals(NodeSnapshot::STATUS_RUNNING, $nodeState->status);
  }

  /**
   * @covers ::setNodeSkipped
   */
  public function testSetNodeSkipped(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );
    $snapshot = $snapshot->withInitializedNodes([["id" => "node_1"]]);

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $updated = $this->stateManager->setNodeSkipped(
      executionId: $snapshot->executionId,
      nodeId: "node_1",
      reason: "branch_not_active",
    );

    $nodeState = $updated->getNodeState("node_1");
    $this->assertEquals(NodeSnapshot::STATUS_SKIPPED, $nodeState->status);
  }

  /**
   * @covers ::updateStatus
   */
  public function testUpdateStatus(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $updated = $this->stateManager->updateStatus(
      executionId: $snapshot->executionId,
      status: "running",
    );

    $this->assertEquals("running", $updated->status);
  }

  /**
   * @covers ::markNodesCompleted
   */
  public function testMarkNodesCompleted(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );
    $snapshot = $snapshot->withInitializedNodes([
      ["id" => "node_1"],
      ["id" => "node_2"],
      ["id" => "node_3"],
    ]);

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $updated = $this->stateManager->markNodesCompleted(
      executionId: $snapshot->executionId,
      nodeOutputs: [
        "node_1" => ["out" => "data1"],
        "node_2" => ["out" => "data2"],
      ],
    );

    // Node 1 and 2 should be completed and injected.
    $node1 = $updated->getNodeState("node_1");
    $node2 = $updated->getNodeState("node_2");
    $node3 = $updated->getNodeState("node_3");

    $this->assertEquals(NodeSnapshot::STATUS_COMPLETED, $node1->status);
    $this->assertTrue($node1->injected);
    $this->assertEquals(NodeSnapshot::STATUS_COMPLETED, $node2->status);
    $this->assertTrue($node2->injected);
    $this->assertEquals(NodeSnapshot::STATUS_PENDING, $node3->status);
  }

  /**
   * @covers ::computeWorkflowVersion
   */
  public function testComputeWorkflowVersion(): void {
    $workflowDef = [
      "nodes" => [
        ["id" => "node_1", "data" => ["type" => "input"]],
        ["id" => "node_2", "data" => ["type" => "output"]],
      ],
      "edges" => [
        ["id" => "edge_1", "source" => "node_1", "target" => "node_2"],
      ],
    ];

    $version1 = $this->stateManager->computeWorkflowVersion($workflowDef);

    // Should return a non-empty string.
    $this->assertNotEmpty($version1);
    $this->assertEquals(16, strlen($version1));

    // Same definition should produce same version.
    $version2 = $this->stateManager->computeWorkflowVersion($workflowDef);
    $this->assertEquals($version1, $version2);

    // Different definition should produce different version.
    $differentDef = [
      "nodes" => [
        ["id" => "node_1", "data" => ["type" => "input"]],
        ["id" => "node_3", "data" => ["type" => "output"]],
      ],
      "edges" => [],
    ];
    $version3 = $this->stateManager->computeWorkflowVersion($differentDef);
    $this->assertNotEquals($version1, $version3);
  }

  /**
   * @covers ::delete
   */
  public function testDelete(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $deleted = $this->stateManager->delete($snapshot->executionId);
    $this->assertTrue($deleted);

    // Should no longer be retrievable from in-memory cache.
    $retrieved = $this->stateManager->getSnapshot($snapshot->executionId);
    $this->assertNull($retrieved);
  }

  /**
   * @covers ::delete
   */
  public function testDeleteReturnsFalseForNonexistent(): void {
    $deleted = $this->stateManager->delete("nonexistent_id");
    $this->assertFalse($deleted);
  }

  /**
   * @covers ::initializeFromSnapshot
   */
  public function testInitializeFromSnapshot(): void {
    $source = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
      initialInput: ["original" => "data"],
    );
    $source = $source->withInitializedNodes([
      ["id" => "node_1"],
      ["id" => "node_2"],
    ]);
    $source = $source->withNodeCompleted("node_1", ["result" => "done"], 1, FALSE);

    $newSnapshot = $this->stateManager->initializeFromSnapshot($source, TRUE);

    // Should have different execution ID.
    $this->assertNotEquals($source->executionId, $newSnapshot->executionId);

    // Should preserve workflow ID.
    $this->assertEquals($source->workflowId, $newSnapshot->workflowId);

    // Completed node should be marked as injected.
    $node1 = $newSnapshot->getNodeState("node_1");
    $this->assertEquals(NodeSnapshot::STATUS_COMPLETED, $node1->status);
    $this->assertTrue($node1->injected);
  }

  /**
   * @covers ::addMessage
   */
  public function testAddMessage(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $updated = $this->stateManager->addMessage(
      executionId: $snapshot->executionId,
      role: "user",
      content: "Hello, world!",
      metadata: ["source" => "test"],
    );

    $this->assertCount(1, $updated->messages);
    $this->assertEquals("user", $updated->messages[0]["role"]);
    $this->assertEquals("Hello, world!", $updated->messages[0]["content"]);
  }

  /**
   * @covers ::incrementIteration
   */
  public function testIncrementIteration(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "abc123",
    );

    $this->assertEquals(0, $snapshot->iterationCount);

    $this->entityStorage->save(Argument::any())->shouldBeCalled();
    $this->stateManager->save($snapshot);

    $updated = $this->stateManager->incrementIteration($snapshot->executionId);
    $this->assertEquals(1, $updated->iterationCount);

    $updated = $this->stateManager->incrementIteration($snapshot->executionId);
    $this->assertEquals(2, $updated->iterationCount);
  }

  /**
   * @covers ::validateSnapshot
   */
  public function testValidateSnapshotVersionMismatch(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "old_version",
    );

    $workflowDef = [
      "nodes" => [["id" => "node_1", "data" => ["type" => "input"]]],
      "edges" => [],
    ];

    $result = $this->stateManager->validateSnapshot($snapshot, $workflowDef);

    // Should have version mismatch error.
    $this->assertFalse($result->isValid());
    $this->assertTrue($result->hasErrors());
  }

  /**
   * @covers ::validateSnapshot
   */
  public function testValidateSnapshotUnknownNode(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "",
    );
    $snapshot = $snapshot->withInitializedNodes([
      ["id" => "unknown_node"],
    ]);

    $workflowDef = [
      "nodes" => [["id" => "node_1", "data" => ["type" => "input"]]],
      "edges" => [],
    ];

    $result = $this->stateManager->validateSnapshot($snapshot, $workflowDef);

    // Should have unknown node error.
    $this->assertFalse($result->isValid());
  }

  /**
   * A completed node whose dependency is skipped is a consistent state.
   *
   * @covers ::validateDependencies
   */
  public function testValidateDependenciesSkippedDependencyIsValid(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "",
    );
    $snapshot = $snapshot
      ->withNodeState(NodeSnapshot::skipped("dep"))
      ->withNodeCompleted("node_1", ["result" => "ok"]);

    $workflowDef = [
      "nodes" => [["id" => "dep"], ["id" => "node_1"]],
      "edges" => [["source" => "dep", "target" => "node_1"]],
    ];

    $result = $this->stateManager->validateDependencies($snapshot, $workflowDef);

    $this->assertTrue($result->isValid());
    $this->assertFalse($result->hasErrors());
  }

  /**
   * A completed node whose dependency never ran is inconsistent.
   *
   * @covers ::validateDependencies
   */
  public function testValidateDependenciesUnmetDependencyIsError(): void {
    $snapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "",
    );
    $snapshot = $snapshot
      ->withNodeState(NodeSnapshot::running("dep"))
      ->withNodeCompleted("node_1", ["result" => "ok"]);

    $workflowDef = [
      "nodes" => [["id" => "dep"], ["id" => "node_1"]],
      "edges" => [["source" => "dep", "target" => "node_1"]],
    ];

    $result = $this->stateManager->validateDependencies($snapshot, $workflowDef);

    $this->assertFalse($result->isValid());
    $this->assertTrue($result->hasErrors());
  }

  /**
   * @covers ::listByWorkflow
   */
  public function testListByWorkflow(): void {
    // Save multiple snapshots for same workflow.
    $this->entityStorage->save(Argument::any())->shouldBeCalled();

    $snapshot1 = WorkflowSnapshot::create(
      workflowId: "my_workflow",
      workflowVersion: "v1",
    );
    $this->stateManager->save($snapshot1);

    $snapshot2 = WorkflowSnapshot::create(
      workflowId: "my_workflow",
      workflowVersion: "v1",
    );
    $this->stateManager->save($snapshot2);

    $snapshot3 = WorkflowSnapshot::create(
      workflowId: "other_workflow",
      workflowVersion: "v1",
    );
    $this->stateManager->save($snapshot3);

    $list = $this->stateManager->listByWorkflow("my_workflow");

    $this->assertCount(2, $list);
    foreach ($list as $snap) {
      $this->assertEquals("my_workflow", $snap->workflowId);
    }
  }

  /**
   * @covers ::cleanup
   */
  public function testCleanup(): void {
    $this->entityStorage->save(Argument::any())->shouldBeCalled();

    // Create an "old" snapshot by manipulating createdAt.
    $oldSnapshot = new WorkflowSnapshot(
      workflowId: "test_workflow",
      workflowVersion: "v1",
      executionId: "old_exec_" . uniqid(),
      status: "completed",
      nodeStates: [],
      initialInput: [],
      messages: [],
      iterator: NULL,
      iterationCount: 0,
      threadId: "",
      error: NULL,
      metadata: [],
      createdAt: time() - 86400 * 10,
      updatedAt: time() - 86400 * 10,
    );
    $this->stateManager->save($oldSnapshot);

    // Create a "new" snapshot.
    $newSnapshot = WorkflowSnapshot::create(
      workflowId: "test_workflow",
      workflowVersion: "v1",
    );
    $this->stateManager->save($newSnapshot);

    // Cleanup snapshots older than 7 days.
    $deleted = $this->stateManager->cleanup(
      olderThan: time() - 86400 * 7,
      statuses: ["completed"],
    );

    $this->assertEquals(1, $deleted);

    // Old snapshot should be gone.
    $this->assertNull($this->stateManager->getSnapshot($oldSnapshot->executionId));

    // New snapshot should remain.
    $this->assertNotNull($this->stateManager->getSnapshot($newSnapshot->executionId));
  }

}
