<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_stategraph\Unit\Service;

use Prophecy\Argument;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop_stategraph\DTO\Checkpoint;
use Drupal\flowdrop_stategraph\DTO\GraphState;
use Drupal\flowdrop_orchestration\DTO\Snapshot\Message;
use Drupal\flowdrop_stategraph\DTO\StateUpdate;
use Drupal\flowdrop_stategraph\Exception\MaxIterationsException;
use Drupal\flowdrop_stategraph\Exception\StateValidationException;
use Drupal\flowdrop_stategraph\Service\StateManager;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;

/**
 * Tests the StateManager service.
 *
 * @coversDefaultClass \Drupal\flowdrop_stategraph\Service\StateManager
 * @group flowdrop_stategraph
 */
class StateManagerTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The state manager under test.
   *
   * @var \Drupal\flowdrop_stategraph\Service\StateManager
   */
  private StateManager $stateManager;

  /**
   * Mock config factory.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<\Drupal\Core\Config\ConfigFactoryInterface>
   */
  private $configFactory;

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

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

    $this->configFactory = $this->prophesize(ConfigFactoryInterface::class);
    $config = $this->prophesize(ImmutableConfig::class);
    $config->get("max_iterations")->willReturn(100);
    $this->configFactory->get("flowdrop_stategraph.settings")->willReturn($config->reveal());

    $this->stateManager = new StateManager(
      $logger->reveal(),
      $this->configFactory->reveal(),
    );
  }

  /**
   * Tests createInitialState creates state with provided thread ID.
   *
   * @covers ::createInitialState
   */
  public function testCreateInitialState(): void {
    $state = $this->stateManager->createInitialState(
      "custom_thread",
      ["initial" => "data"]
    );

    $this->assertEquals("custom_thread", $state->threadId);
    $this->assertEquals(["initial" => "data"], $state->data);
    $this->assertEquals(0, $state->iterationCount);
    $this->assertFalse($state->isComplete);
    $this->assertNull($state->error);
    $this->assertTrue($state->metadata["workflow_start"]);
  }

  /**
   * Tests createInitialState generates thread ID if not provided.
   *
   * @covers ::createInitialState
   */
  public function testCreateInitialStateGeneratesThreadId(): void {
    $state = $this->stateManager->createInitialState();

    $this->assertStringStartsWith("thread_", $state->threadId);
    $this->assertGreaterThan(7, strlen($state->threadId));
  }

  /**
   * Tests applyUpdate applies state update using reducers.
   *
   * @covers ::applyUpdate
   */
  public function testApplyUpdate(): void {
    $state = new GraphState(
      threadId: "thread_1",
      data: ["existing" => "value"],
    );

    $update = StateUpdate::create([
      "data" => ["new" => "data"],
      "currentNodeId" => "node_1",
    ]);

    $newState = $this->stateManager->applyUpdate($state, $update);

    // Data should be merged.
    $this->assertEquals("value", $newState->data["existing"]);
    $this->assertEquals("data", $newState->data["new"]);

    // currentNodeId should be replaced.
    $this->assertEquals("node_1", $newState->currentNodeId);

    // Original unchanged.
    $this->assertEquals("", $state->currentNodeId);
  }

  /**
   * Tests applyUpdate with message accumulation.
   *
   * @covers ::applyUpdate
   */
  public function testApplyUpdateWithMessages(): void {
    $existingMessage = new Message("user", "Hello");
    $state = new GraphState(
      threadId: "thread_1",
      messages: [$existingMessage],
    );

    $newMessage = new Message("assistant", "Hi there");
    $update = StateUpdate::create([
      "messages" => [$newMessage],
    ]);

    $newState = $this->stateManager->applyUpdate($state, $update);

    $this->assertCount(2, $newState->messages);
    $this->assertEquals("Hello", $newState->messages[0]->content);
    $this->assertEquals("Hi there", $newState->messages[1]->content);
  }

  /**
   * Tests applyUpdate throws when max iterations exceeded.
   *
   * @covers ::applyUpdate
   */
  public function testApplyUpdateThrowsMaxIterationsException(): void {
    $state = new GraphState(
      threadId: "thread_1",
      iterationCount: 100,
    );

    $update = StateUpdate::create([
      "iterationCount" => 101,
    ])->withSourceNode("test_node");

    $this->expectException(MaxIterationsException::class);
    $this->stateManager->applyUpdate($state, $update, 100);
  }

  /**
   * Tests incrementIteration increments the count.
   *
   * @covers ::incrementIteration
   */
  public function testIncrementIteration(): void {
    $state = new GraphState(
      threadId: "thread_1",
      iterationCount: 5,
    );

    $newState = $this->stateManager->incrementIteration($state);

    $this->assertEquals(6, $newState->iterationCount);
    $this->assertEquals(5, $state->iterationCount);
  }

  /**
   * Tests incrementIteration throws when max exceeded.
   *
   * @covers ::incrementIteration
   */
  public function testIncrementIterationThrowsMaxIterationsException(): void {
    $state = new GraphState(
      threadId: "thread_1",
      iterationCount: 99,
    );

    // First increment should work.
    $newState = $this->stateManager->incrementIteration($state, 100);
    $this->assertEquals(100, $newState->iterationCount);

    // Second increment should throw.
    $this->expectException(MaxIterationsException::class);
    $this->stateManager->incrementIteration($newState, 100);
  }

  /**
   * Tests createCheckpoint creates checkpoint from state.
   *
   * @covers ::createCheckpoint
   */
  public function testCreateCheckpoint(): void {
    $state = new GraphState(
      threadId: "thread_test",
      iterationCount: 5,
      isComplete: FALSE,
    );

    $checkpoint = $this->stateManager->createCheckpoint($state, "current_node");

    $this->assertStringStartsWith("cp_", $checkpoint->id);
    $this->assertEquals("thread_test", $checkpoint->threadId);
    $this->assertSame($state, $checkpoint->state);
    $this->assertEquals("current_node", $checkpoint->nodeId);
    $this->assertNull($checkpoint->parentCheckpointId);
    $this->assertEquals(5, $checkpoint->metadata["iteration_count"]);
    $this->assertFalse($checkpoint->metadata["is_complete"]);
  }

  /**
   * Tests createCheckpoint with parent checkpoint.
   *
   * @covers ::createCheckpoint
   */
  public function testCreateCheckpointWithParent(): void {
    $state = new GraphState(threadId: "thread_1");

    $checkpoint = $this->stateManager->createCheckpoint(
      $state,
      "node_2",
      "cp_parent_123"
    );

    $this->assertEquals("cp_parent_123", $checkpoint->parentCheckpointId);
  }

  /**
   * Tests restoreFromCheckpoint returns the state.
   *
   * @covers ::restoreFromCheckpoint
   */
  public function testRestoreFromCheckpoint(): void {
    $originalState = new GraphState(
      threadId: "thread_restore",
      data: ["key" => "value"],
      iterationCount: 10,
    );

    $checkpoint = new Checkpoint(
      id: "cp_restore",
      threadId: "thread_restore",
      state: $originalState,
      nodeId: "restore_node",
      createdAt: time(),
    );

    $restoredState = $this->stateManager->restoreFromCheckpoint($checkpoint);

    $this->assertSame($originalState, $restoredState);
    $this->assertEquals("value", $restoredState->data["key"]);
    $this->assertEquals(10, $restoredState->iterationCount);
  }

  /**
   * Tests markComplete marks state as complete.
   *
   * @covers ::markComplete
   */
  public function testMarkComplete(): void {
    $state = new GraphState(
      threadId: "thread_1",
      isComplete: FALSE,
    );

    $completedState = $this->stateManager->markComplete($state);

    $this->assertTrue($completedState->isComplete);
    $this->assertFalse($state->isComplete);
  }

  /**
   * Tests markError sets error and marks complete.
   *
   * @covers ::markError
   */
  public function testMarkError(): void {
    $state = new GraphState(
      threadId: "thread_1",
      isComplete: FALSE,
    );

    $errorState = $this->stateManager->markError($state, "Something failed");

    $this->assertEquals("Something failed", $errorState->error);
    $this->assertTrue($errorState->isComplete);
    $this->assertNull($state->error);
    $this->assertFalse($state->isComplete);
  }

  /**
   * Tests validateState passes for valid state.
   *
   * @covers ::validateState
   */
  public function testValidateState(): void {
    $state = new GraphState(
      threadId: "valid_thread",
      iterationCount: 5,
    );

    // Should not throw.
    $this->stateManager->validateState($state);
    $this->assertTrue(TRUE);
  }

  /**
   * Tests validateState throws on empty thread ID.
   *
   * @covers ::validateState
   */
  public function testValidateStateThrowsOnEmptyThreadId(): void {
    $state = new GraphState(
      threadId: "",
      iterationCount: 5,
    );

    $this->expectException(StateValidationException::class);
    $this->stateManager->validateState($state);
  }

  /**
   * Tests validateState throws on negative iteration count.
   *
   * @covers ::validateState
   */
  public function testValidateStateThrowsOnNegativeIterationCount(): void {
    $state = new GraphState(
      threadId: "thread_1",
      iterationCount: -1,
    );

    $this->expectException(StateValidationException::class);
    $this->stateManager->validateState($state);
  }

  /**
   * Tests getReducer returns correct reducer.
   *
   * @covers ::getReducer
   */
  public function testGetReducer(): void {
    $appendReducer = $this->stateManager->getReducer("append");
    $this->assertEquals("append", $appendReducer->getId());

    $replaceReducer = $this->stateManager->getReducer("replace");
    $this->assertEquals("replace", $replaceReducer->getId());

    $mergeReducer = $this->stateManager->getReducer("merge");
    $this->assertEquals("merge", $mergeReducer->getId());
  }

  /**
   * Tests getReducer throws on unknown reducer.
   *
   * @covers ::getReducer
   */
  public function testGetReducerThrowsOnUnknown(): void {
    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage("Reducer 'unknown_reducer' not found");

    $this->stateManager->getReducer("unknown_reducer");
  }

  /**
   * Tests getReducers returns all reducers.
   *
   * @covers ::getReducers
   */
  public function testGetReducers(): void {
    $reducers = $this->stateManager->getReducers();

    $this->assertArrayHasKey("append", $reducers);
    $this->assertArrayHasKey("replace", $reducers);
    $this->assertArrayHasKey("merge", $reducers);
    $this->assertCount(3, $reducers);
  }

  /**
   * Tests getMaxIterations returns configured value.
   *
   * @covers ::getMaxIterations
   */
  public function testGetMaxIterations(): void {
    $maxIterations = $this->stateManager->getMaxIterations();

    $this->assertEquals(100, $maxIterations);
  }

  /**
   * Tests getMaxIterations returns default when not configured.
   *
   * @covers ::getMaxIterations
   */
  public function testGetMaxIterationsDefault(): void {
    $config = $this->prophesize(ImmutableConfig::class);
    $config->get("max_iterations")->willReturn(NULL);
    $this->configFactory->get("flowdrop_stategraph.settings")->willReturn($config->reveal());

    $maxIterations = $this->stateManager->getMaxIterations();

    $this->assertEquals(100, $maxIterations);
  }

  /**
   * Tests getStateFieldMetadata extracts field metadata.
   *
   * @covers ::getStateFieldMetadata
   */
  public function testGetStateFieldMetadata(): void {
    $state = new GraphState(threadId: "test");

    $metadata = $this->stateManager->getStateFieldMetadata($state);

    // Should have metadata for fields with StateField attribute.
    $this->assertArrayHasKey("messages", $metadata);
    $this->assertEquals("append", $metadata["messages"]["reducer"]);

    $this->assertArrayHasKey("data", $metadata);
    $this->assertEquals("merge", $metadata["data"]["reducer"]);

    $this->assertArrayHasKey("currentNodeId", $metadata);
    $this->assertEquals("replace", $metadata["currentNodeId"]["reducer"]);
  }

  /**
   * Tests multiple state updates in sequence.
   *
   * @covers ::applyUpdate
   */
  public function testMultipleSequentialUpdates(): void {
    $state = $this->stateManager->createInitialState("thread_1", []);

    // First update.
    $state = $this->stateManager->applyUpdate(
      $state,
      StateUpdate::create([
        "data" => ["step1" => "complete"],
        "currentNodeId" => "node_1",
      ])
    );

    // Second update.
    $state = $this->stateManager->applyUpdate(
      $state,
      StateUpdate::create([
        "data" => ["step2" => "complete"],
        "currentNodeId" => "node_2",
        "messages" => [new Message("user", "Hello")],
      ])
    );

    // Third update.
    $state = $this->stateManager->applyUpdate(
      $state,
      StateUpdate::create([
        "data" => ["step3" => "complete"],
        "messages" => [new Message("assistant", "Hi")],
      ])
    );

    // Verify accumulated state.
    $this->assertEquals("complete", $state->data["step1"]);
    $this->assertEquals("complete", $state->data["step2"]);
    $this->assertEquals("complete", $state->data["step3"]);
    $this->assertEquals("node_2", $state->currentNodeId);
    $this->assertCount(2, $state->messages);
  }

  /**
   * Tests state immutability through updates.
   *
   * @covers ::applyUpdate
   */
  public function testStateImmutability(): void {
    $original = $this->stateManager->createInitialState("thread_1", ["key" => "original"]);

    $updated = $this->stateManager->applyUpdate(
      $original,
      StateUpdate::create(["data" => ["key" => "modified"]])
    );

    // Original should be unchanged.
    $this->assertEquals("original", $original->data["key"]);

    // Updated should have new value.
    $this->assertEquals("modified", $updated->data["key"]);
  }

}
