<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_stategraph\Unit\DTO;

use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop_stategraph\DTO\GraphState;
use Drupal\flowdrop_orchestration\DTO\Snapshot\IteratorState;
use Drupal\flowdrop_orchestration\DTO\Snapshot\Message;
use Drupal\flowdrop_stategraph\DTO\StateUpdate;
use Drupal\flowdrop_stategraph\Reducer\AppendReducer;
use Drupal\flowdrop_stategraph\Reducer\MergeReducer;
use Drupal\flowdrop_stategraph\Reducer\ReplaceReducer;

/**
 * Tests the GraphState DTO.
 *
 * @coversDefaultClass \Drupal\flowdrop_stategraph\DTO\GraphState
 * @group flowdrop_stategraph
 */
class GraphStateTest extends UnitTestCase {

  /**
   * Map of reducer ID to reducer instance.
   *
   * @var array<string, \Drupal\flowdrop_stategraph\Reducer\ReducerInterface>
   */
  private array $reducers;

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

    // Initialize reducers for testing.
    $appendReducer = new AppendReducer();
    $replaceReducer = new ReplaceReducer();
    $mergeReducer = new MergeReducer();

    $this->reducers = [
      $appendReducer->getId() => $appendReducer,
      $replaceReducer->getId() => $replaceReducer,
      $mergeReducer->getId() => $mergeReducer,
    ];
  }

  /**
   * Tests that constructor sets default values.
   *
   * @covers ::__construct
   */
  public function testConstructorDefaults(): void {
    $state = new GraphState();

    $this->assertEquals([], $state->messages);
    $this->assertEquals([], $state->data);
    $this->assertEquals([], $state->metadata);
    $this->assertNull($state->iterator);
    $this->assertEquals("", $state->currentNodeId);
    $this->assertEquals(0, $state->iterationCount);
    $this->assertEquals("", $state->threadId);
    $this->assertFalse($state->isComplete);
    $this->assertNull($state->error);
  }

  /**
   * Tests constructor with custom values.
   *
   * @covers ::__construct
   */
  public function testConstructorWithValues(): void {
    $message = new Message("user", "Hello");
    $iterator = IteratorState::fromItems(["a", "b"]);

    $state = new GraphState(
      messages: [$message],
      data: ["key" => "value"],
      metadata: ["created" => 1234567890],
      iterator: $iterator,
      currentNodeId: "node_1",
      iterationCount: 5,
      threadId: "thread_abc",
      isComplete: TRUE,
      error: "Test error",
    );

    $this->assertCount(1, $state->messages);
    $this->assertEquals("Hello", $state->messages[0]->content);
    $this->assertEquals(["key" => "value"], $state->data);
    $this->assertEquals(["created" => 1234567890], $state->metadata);
    $this->assertInstanceOf(IteratorState::class, $state->iterator);
    $this->assertEquals("node_1", $state->currentNodeId);
    $this->assertEquals(5, $state->iterationCount);
    $this->assertEquals("thread_abc", $state->threadId);
    $this->assertTrue($state->isComplete);
    $this->assertEquals("Test error", $state->error);
  }

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

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

    $newState = $state->withUpdate($update, $this->reducers);

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

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

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

  /**
   * Tests that messages field uses append reducer.
   *
   * @covers ::withUpdate
   */
  public function testWithUpdateMessagesAppend(): void {
    $existingMessage = new Message("user", "Hello");
    $newMessage = new Message("assistant", "Hi there");

    $state = new GraphState(
      messages: [$existingMessage],
      threadId: "thread_1",
    );

    $update = StateUpdate::create([
      "messages" => [$newMessage],
    ]);

    $newState = $state->withUpdate($update, $this->reducers);

    // Messages should be appended.
    $this->assertCount(2, $newState->messages);
    $this->assertEquals("Hello", $newState->messages[0]->content);
    $this->assertEquals("Hi there", $newState->messages[1]->content);
  }

  /**
   * Tests that plain-array message appends are normalized to Message objects.
   *
   * Processors that round-trip message data through output serialization
   * append plain arrays, not Message objects. withUpdate() must normalize
   * them — otherwise a later toArray() fatals on array-to-Message calls.
   *
   * @covers ::withUpdate
   */
  public function testWithUpdateMessagesNormalizesPlainArrays(): void {
    $state = new GraphState(
      messages: [new Message("user", "Hello")],
      threadId: "thread_1",
    );

    $update = StateUpdate::create([
      "messages" => [
        ["role" => "assistant", "content" => "Hi", "metadata" => ["tool_calls" => [["name" => "lookup", "args" => []]]]],
        ["role" => "tool", "content" => "42", "metadata" => ["tool_call_id" => "call_1"]],
      ],
    ]);

    $newState = $state->withUpdate($update, $this->reducers);

    $this->assertCount(3, $newState->messages);
    $this->assertContainsOnlyInstancesOf(Message::class, $newState->messages);
    $this->assertEquals("assistant", $newState->messages[1]->role);
    $this->assertTrue($newState->messages[1]->hasToolCalls());
    $this->assertEquals("call_1", $newState->messages[2]->getToolCallId());

    // The original fatal: serializing a state whose messages channel holds
    // plain arrays. Must not throw.
    $array = $newState->toArray();
    $this->assertCount(3, $array["messages"]);
  }

  /**
   * Tests that a single message as a bare associative array appends as one.
   *
   * Without the wrap, the append reducer iterates the associative array and
   * appends its scalar values one by one, silently corrupting the channel.
   *
   * @covers ::withUpdate
   */
  public function testWithUpdateMessagesSingleAssociativeArray(): void {
    $state = new GraphState(threadId: "thread_1");

    $update = StateUpdate::create([
      "messages" => ["role" => "user", "content" => "Hello"],
    ]);

    $newState = $state->withUpdate($update, $this->reducers);

    $this->assertCount(1, $newState->messages);
    $this->assertInstanceOf(Message::class, $newState->messages[0]);
    $this->assertEquals("user", $newState->messages[0]->role);
    $this->assertEquals("Hello", $newState->messages[0]->content);
  }

  /**
   * Tests appending a mix of Message objects and plain arrays.
   *
   * @covers ::withUpdate
   */
  public function testWithUpdateMessagesMixedAppend(): void {
    $state = new GraphState(threadId: "thread_1");

    $update = StateUpdate::create([
      "messages" => [
        new Message("user", "Question"),
        ["role" => "assistant", "content" => "Answer"],
      ],
    ]);

    $newState = $state->withUpdate($update, $this->reducers);

    $this->assertCount(2, $newState->messages);
    $this->assertContainsOnlyInstancesOf(Message::class, $newState->messages);
    $this->assertEquals("Question", $newState->messages[0]->content);
    $this->assertEquals("Answer", $newState->messages[1]->content);
  }

  /**
   * Tests that data field uses merge reducer.
   *
   * @covers ::withUpdate
   */
  public function testWithUpdateDataMerge(): void {
    $state = new GraphState(
      data: ["key1" => "value1", "nested" => ["a" => 1]],
      threadId: "thread_1",
    );

    $update = StateUpdate::create([
      "data" => ["key2" => "value2", "nested" => ["b" => 2]],
    ]);

    $newState = $state->withUpdate($update, $this->reducers);

    // Data should be deep merged.
    $this->assertEquals("value1", $newState->data["key1"]);
    $this->assertEquals("value2", $newState->data["key2"]);
    $this->assertEquals(1, $newState->data["nested"]["a"]);
    $this->assertEquals(2, $newState->data["nested"]["b"]);
  }

  /**
   * Tests that replace fields use replace reducer.
   *
   * @covers ::withUpdate
   */
  public function testWithUpdateReplaceFields(): void {
    $state = new GraphState(
      currentNodeId: "node_1",
      iterationCount: 5,
      threadId: "thread_1",
    );

    $update = StateUpdate::create([
      "currentNodeId" => "node_2",
      "iterationCount" => 10,
    ]);

    $newState = $state->withUpdate($update, $this->reducers);

    // Replace fields should be completely replaced.
    $this->assertEquals("node_2", $newState->currentNodeId);
    $this->assertEquals(10, $newState->iterationCount);
  }

  /**
   * Tests withIncrementedIteration increments correctly.
   *
   * @covers ::withIncrementedIteration
   */
  public function testWithIncrementedIteration(): void {
    $state = new GraphState(
      iterationCount: 5,
      threadId: "thread_1",
    );

    $newState = $state->withIncrementedIteration();

    $this->assertEquals(6, $newState->iterationCount);
    // Original unchanged.
    $this->assertEquals(5, $state->iterationCount);
  }

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

    $newState = $state->withComplete();

    $this->assertTrue($newState->isComplete);
    // Original unchanged.
    $this->assertFalse($state->isComplete);
  }

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

    $newState = $state->withError("Something went wrong");

    $this->assertEquals("Something went wrong", $newState->error);
    $this->assertTrue($newState->isComplete);
    // Original unchanged.
    $this->assertNull($state->error);
    $this->assertFalse($state->isComplete);
  }

  /**
   * Tests toArray serialization.
   *
   * @covers ::toArray
   */
  public function testToArray(): void {
    $message = new Message("user", "Test");
    $iterator = IteratorState::fromItems(["x"]);

    $state = new GraphState(
      messages: [$message],
      data: ["key" => "value"],
      metadata: ["meta" => "data"],
      iterator: $iterator,
      currentNodeId: "node_1",
      iterationCount: 3,
      threadId: "thread_abc",
      isComplete: TRUE,
      error: "Error message",
    );

    $array = $state->toArray();

    $this->assertIsArray($array);
    $this->assertCount(1, $array["messages"]);
    $this->assertEquals("Test", $array["messages"][0]["content"]);
    $this->assertEquals(["key" => "value"], $array["data"]);
    $this->assertEquals(["meta" => "data"], $array["metadata"]);
    $this->assertIsArray($array["iterator"]);
    $this->assertEquals("node_1", $array["currentNodeId"]);
    $this->assertEquals(3, $array["iterationCount"]);
    $this->assertEquals("thread_abc", $array["threadId"]);
    $this->assertTrue($array["isComplete"]);
    $this->assertEquals("Error message", $array["error"]);
  }

  /**
   * Tests fromArray deserialization.
   *
   * @covers ::fromArray
   */
  public function testFromArray(): void {
    $data = [
      "messages" => [
        ["role" => "user", "content" => "Hello", "metadata" => [], "timestamp" => 1234567890],
      ],
      "data" => ["key" => "value"],
      "metadata" => ["created" => 1234567890],
      "iterator" => [
        "items" => ["a", "b"],
        "currentIndex" => 0,
        "currentItem" => "a",
        "hasMore" => TRUE,
        "results" => [],
      ],
      "currentNodeId" => "node_1",
      "iterationCount" => 5,
      "threadId" => "thread_xyz",
      "isComplete" => FALSE,
      "error" => NULL,
    ];

    $state = GraphState::fromArray($data);

    $this->assertCount(1, $state->messages);
    $this->assertEquals("Hello", $state->messages[0]->content);
    $this->assertEquals(["key" => "value"], $state->data);
    $this->assertEquals(["created" => 1234567890], $state->metadata);
    $this->assertInstanceOf(IteratorState::class, $state->iterator);
    $this->assertEquals("a", $state->iterator->currentItem);
    $this->assertEquals("node_1", $state->currentNodeId);
    $this->assertEquals(5, $state->iterationCount);
    $this->assertEquals("thread_xyz", $state->threadId);
    $this->assertFalse($state->isComplete);
    $this->assertNull($state->error);
  }

  /**
   * Tests fromArray with minimal data.
   *
   * @covers ::fromArray
   */
  public function testFromArrayWithDefaults(): void {
    $state = GraphState::fromArray([]);

    $this->assertEquals([], $state->messages);
    $this->assertEquals([], $state->data);
    $this->assertEquals([], $state->metadata);
    $this->assertNull($state->iterator);
    $this->assertEquals("", $state->currentNodeId);
    $this->assertEquals(0, $state->iterationCount);
    $this->assertEquals("", $state->threadId);
    $this->assertFalse($state->isComplete);
    $this->assertNull($state->error);
  }

  /**
   * Tests JSON serialization.
   *
   * @covers ::jsonSerialize
   */
  public function testJsonSerialize(): void {
    $state = new GraphState(
      threadId: "thread_1",
      data: ["key" => "value"],
    );

    $json = json_encode($state);
    $this->assertIsString($json);

    $decoded = json_decode($json, TRUE);
    $this->assertIsArray($decoded);
    $this->assertEquals("thread_1", $decoded["threadId"]);
    $this->assertEquals(["key" => "value"], $decoded["data"]);
  }

  /**
   * Tests round-trip serialization (toArray -> fromArray).
   *
   * @covers ::toArray
   * @covers ::fromArray
   */
  public function testRoundTripSerialization(): void {
    $message = new Message("assistant", "Response");
    $iterator = IteratorState::fromItems([1, 2, 3]);

    $original = new GraphState(
      messages: [$message],
      data: ["nested" => ["deep" => "value"]],
      metadata: ["timestamp" => time()],
      iterator: $iterator,
      currentNodeId: "process_node",
      iterationCount: 7,
      threadId: "thread_roundtrip",
      isComplete: FALSE,
      error: NULL,
    );

    $restored = GraphState::fromArray($original->toArray());

    $this->assertCount(1, $restored->messages);
    $this->assertEquals("Response", $restored->messages[0]->content);
    $this->assertEquals("value", $restored->data["nested"]["deep"]);
    $this->assertEquals("process_node", $restored->currentNodeId);
    $this->assertEquals(7, $restored->iterationCount);
    $this->assertEquals("thread_roundtrip", $restored->threadId);
    $this->assertInstanceOf(IteratorState::class, $restored->iterator);
    $this->assertEquals(1, $restored->iterator->currentItem);
  }

}
