<?php

declare(strict_types=1);

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

use Prophecy\Argument;
use Drupal\flowdrop\DTO\ExecutionContextDTO;
use Drupal\flowdrop\DTO\ParameterBagInterface;
use Drupal\flowdrop\Exception\InvalidNodeConfigurationException;
use Drupal\flowdrop_memory\Plugin\FlowDropNodeProcessor\ConversationBuffer;
use Drupal\flowdrop_memory\Service\MemoryManager;
use Drupal\Tests\UnitTestCase;
use Prophecy\PhpUnit\ProphecyTrait;

/**
 * Tests the ConversationBuffer node processor.
 *
 * @coversDefaultClass \Drupal\flowdrop_memory\Plugin\FlowDropNodeProcessor\ConversationBuffer
 * @group flowdrop_memory
 */
class ConversationBufferTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The memory manager mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\flowdrop_memory\Service\MemoryManager
   */
  private $memoryManager;

  /**
   * The processor under test.
   *
   * @var \Drupal\flowdrop_memory\Plugin\FlowDropNodeProcessor\ConversationBuffer
   */
  private ConversationBuffer $processor;

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

    $this->memoryManager = $this->prophesize(MemoryManager::class);
    $this->processor = new ConversationBuffer(
      [],
      'conversation_buffer',
      ['id' => 'conversation_buffer'],
      $this->memoryManager->reveal(),
    );
  }

  /**
   * Builds an ExecutionContextDTO for testing.
   */
  private function makeContext(
    string $workflowId = 'wf-id',
    string $pipelineId = 'pl-id',
    string $executionId = 'ex-id',
    array $metadata = [],
  ): ExecutionContextDTO {
    return new ExecutionContextDTO([], $workflowId, $pipelineId, $executionId, 'node-id', $metadata);
  }

  /**
   * Stubs a ParameterBagInterface prophecy.
   */
  private function stubParams(
    string $scope,
    string $key,
    string $role,
    mixed $content,
    int $maxMessages = 0,
  ) {
    $params = $this->prophesize(ParameterBagInterface::class);
    $params->getString('scope', 'session')->willReturn($scope);
    $params->getString('key', 'chat_history')->willReturn($key);
    // No message input: the simple role/content path is used.
    $params->get('message')->willReturn(NULL);
    $params->getString('role')->willReturn($role);
    $params->get('content')->willReturn($content);
    $params->getInt('max_messages', 0)->willReturn($maxMessages);
    $params->getString('backend', 'static')->willReturn('static');
    return $params;
  }

  /**
   * Stubs a ParameterBagInterface driven by the `message` input.
   *
   * @param mixed $message
   *   The `message` input value (single message object or list of them).
   * @param int $maxMessages
   *   The sliding-window size.
   */
  private function stubMessageParams(mixed $message, int $maxMessages = 0) {
    $params = $this->prophesize(ParameterBagInterface::class);
    $params->getString('scope', 'session')->willReturn('session');
    $params->getString('key', 'chat_history')->willReturn('chat_history');
    $params->getInt('max_messages', 0)->willReturn($maxMessages);
    $params->getString('backend', 'static')->willReturn('static');
    $params->get('message')->willReturn($message);
    return $params;
  }

  // ---------------------------------------------------------------------------
  // validateParams
  // ---------------------------------------------------------------------------

  /**
   * @covers ::validateParams
   */
  public function testValidateParamsFailsWhenRoleMissing(): void {
    $result = $this->processor->validateParams(['content' => 'Hello']);
    $this->assertFalse($result->isValid());
  }

  /**
   * @covers ::validateParams
   */
  public function testValidateParamsFailsWhenRoleIsEmpty(): void {
    $result = $this->processor->validateParams(['role' => '', 'content' => 'Hello']);
    $this->assertFalse($result->isValid());
  }

  /**
   * @covers ::validateParams
   */
  public function testValidateParamsFailsWhenContentKeyAbsent(): void {
    $result = $this->processor->validateParams(['role' => 'user']);
    $this->assertFalse($result->isValid());
  }

  /**
   * @covers ::validateParams
   */
  public function testValidateParamsFailsWhenContentIsNull(): void {
    // NULL content is not valid — a conversation message must have
    // actual content.
    $result = $this->processor->validateParams(['role' => 'user', 'content' => NULL]);
    $this->assertFalse($result->isValid());
  }

  /**
   * @covers ::validateParams
   */
  public function testValidateParamsSucceedsWithRoleAndContent(): void {
    $result = $this->processor->validateParams(['role' => 'user', 'content' => 'Hello']);
    $this->assertTrue($result->isValid());
  }

  /**
   * @covers ::validateParams
   */
  public function testValidateParamsSucceedsForAllRoles(): void {
    foreach (['user', 'assistant', 'system', 'tool'] as $role) {
      $result = $this->processor->validateParams(['role' => $role, 'content' => 'msg']);
      $this->assertTrue($result->isValid(), "Expected valid for role: $role");
    }
  }

  /**
   * A connected message input makes role/content unnecessary.
   *
   * @covers ::validateParams
   */
  public function testValidateParamsSucceedsWithMessageInput(): void {
    $result = $this->processor->validateParams([
      'message' => ['role' => 'assistant', 'content' => 'Hi'],
    ]);
    $this->assertTrue($result->isValid());
  }

  /**
   * An empty message input still falls back to requiring role/content.
   *
   * @covers ::validateParams
   */
  public function testValidateParamsFallsBackWhenMessageEmpty(): void {
    $result = $this->processor->validateParams(['message' => []]);
    $this->assertFalse($result->isValid());
  }

  // ---------------------------------------------------------------------------
  // process — basic append behaviour
  // ---------------------------------------------------------------------------

  /**
   * @covers ::process
   */
  public function testProcessAppendsMessageToEmptyBuffer(): void {
    $params = $this->stubParams('session', 'chat_history', 'user', 'Hello');
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', '', 'chat_history', Argument::that(
      fn($messages) => count($messages) === 1 && $messages[0]['role'] === 'user' && $messages[0]['content'] === 'Hello'
    ), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertCount(1, $output['messages']);
    $this->assertSame('user', $output['messages'][0]['role']);
    $this->assertSame('Hello', $output['messages'][0]['content']);
  }

  /**
   * @covers ::process
   */
  public function testProcessAppendsToExistingBuffer(): void {
    $existing = [
      ['role' => 'user', 'content' => 'First', 'timestamp' => 1000],
    ];
    $params = $this->stubParams('session', 'chat_history', 'assistant', 'Reply');
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn($existing);
    $this->memoryManager->set('session', '', 'chat_history', Argument::that(
      fn($messages) => count($messages) === 2 && $messages[1]['role'] === 'assistant'
    ), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertCount(2, $output['messages']);
    $this->assertSame('assistant', $output['messages'][1]['role']);
  }

  /**
   * @covers ::process
   */
  public function testProcessMessageIncludesTimestamp(): void {
    $params = $this->stubParams('session', 'chat_history', 'user', 'Hi');
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertArrayHasKey('timestamp', $output['messages'][0]);
    $this->assertIsInt($output['messages'][0]['timestamp']);
  }

  /**
   * @covers ::process
   */
  public function testProcessResetsCorruptedNonArrayStorage(): void {
    $params = $this->stubParams('session', 'chat_history', 'user', 'Hi');
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn('corrupted');
    $this->memoryManager->set('session', '', 'chat_history', Argument::that(
      fn($messages) => count($messages) === 1
    ), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertCount(1, $output['messages']);
  }

  // ---------------------------------------------------------------------------
  // process — sliding window
  // ---------------------------------------------------------------------------

  /**
   * @covers ::process
   */
  public function testProcessTrimsToMaxMessagesWindow(): void {
    $existing = [
      ['role' => 'user', 'content' => 'A', 'timestamp' => 1],
      ['role' => 'assistant', 'content' => 'B', 'timestamp' => 2],
      ['role' => 'user', 'content' => 'C', 'timestamp' => 3],
    ];
    $params = $this->stubParams('session', 'chat_history', 'assistant', 'D', maxMessages: 3);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn($existing);
    $this->memoryManager->set('session', '', 'chat_history', Argument::that(
      fn($messages) => count($messages) === 3 && $messages[0]['content'] === 'B'
    ), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertCount(3, $output['messages']);
    $this->assertSame('B', $output['messages'][0]['content']);
    $this->assertSame('D', $output['messages'][2]['content']);
  }

  /**
   * @covers ::process
   */
  public function testProcessUnlimitedWhenMaxMessagesIsZero(): void {
    $existing = array_fill(0, 10, ['role' => 'user', 'content' => 'x', 'timestamp' => 1]);
    $params = $this->stubParams('session', 'chat_history', 'user', 'new', maxMessages: 0);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn($existing);
    $this->memoryManager->set('session', '', 'chat_history', Argument::that(
      fn($messages) => count($messages) === 11
    ), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertCount(11, $output['messages']);
  }

  /**
   * @covers ::process
   */
  public function testProcessWindowOfOneKeepsOnlyLatestMessage(): void {
    $existing = [
      ['role' => 'user', 'content' => 'old', 'timestamp' => 1],
    ];
    $params = $this->stubParams('session', 'chat_history', 'assistant', 'new', maxMessages: 1);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn($existing);
    $this->memoryManager->set('session', '', 'chat_history', Argument::that(
      fn($messages) => count($messages) === 1 && $messages[0]['content'] === 'new'
    ), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertCount(1, $output['messages']);
    $this->assertSame('new', $output['messages'][0]['content']);
  }

  // ---------------------------------------------------------------------------
  // process — scope resolution
  // ---------------------------------------------------------------------------

  /**
   * @covers ::process
   */
  public function testProcessDefaultsToSessionScope(): void {
    $params = $this->stubParams('session', 'chat_history', 'user', 'Hi');
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertSame('session', $output['scope']);
    $this->assertSame('', $output['resolved_scope_id']);
  }

  /**
   * @covers ::process
   */
  public function testProcessResolvesSessionScopeFromContext(): void {
    $this->processor->setExecutionContext($this->makeContext(metadata: ['session_id' => 'sess-abc']));

    $params = $this->stubParams('session', 'chat_history', 'user', 'Hi');
    $this->memoryManager->get('session', 'sess-abc', 'chat_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', 'sess-abc', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertSame('sess-abc', $output['resolved_scope_id']);
  }

  /**
   * @covers ::process
   */
  public function testProcessResolvesWorkflowScopeFromContext(): void {
    $this->processor->setExecutionContext($this->makeContext(workflowId: 'wf-99'));

    $params = $this->stubParams('workflow', 'history', 'user', 'Hi');
    $this->memoryManager->get('workflow', 'wf-99', 'history', [], 'static')->willReturn([]);
    $this->memoryManager->set('workflow', 'wf-99', 'history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertSame('wf-99', $output['resolved_scope_id']);
  }

  // ---------------------------------------------------------------------------
  // process — output shape
  // ---------------------------------------------------------------------------

  /**
   * @covers ::process
   */
  public function testProcessReturnsCorrectCount(): void {
    $existing = [
      ['role' => 'user', 'content' => 'A', 'timestamp' => 1],
    ];
    $params = $this->stubParams('session', 'chat_history', 'assistant', 'B');
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn($existing);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertSame(2, $output['count']);
  }

  /**
   * @covers ::process
   */
  public function testProcessReturnsKeyInOutput(): void {
    $params = $this->stubParams('session', 'my_history', 'user', 'Hi');
    $this->memoryManager->get('session', '', 'my_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', '', 'my_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertSame('my_history', $output['key']);
  }

  /**
   * @covers ::process
   */
  public function testProcessExposesAllRequiredOutputKeys(): void {
    $params = $this->stubParams('session', 'chat_history', 'user', 'Hi');
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertArrayHasKey('messages', $output);
    $this->assertArrayHasKey('count', $output);
    $this->assertArrayHasKey('key', $output);
    $this->assertArrayHasKey('scope', $output);
    $this->assertArrayHasKey('resolved_scope_id', $output);
  }

  // ---------------------------------------------------------------------------
  // process — full-message (agent-loop) append
  // ---------------------------------------------------------------------------

  /**
   * An assistant tool call round-trips with tool_calls preserved in metadata.
   *
   * @covers ::process
   */
  public function testProcessAppendsAssistantToolCall(): void {
    $toolCalls = [['id' => 'call_1', 'name' => 'search', 'arguments' => ['q' => 'cats']]];
    $params = $this->stubMessageParams([
      'role' => 'assistant',
      'content' => '',
      'tool_calls' => $toolCalls,
    ]);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    // Empty content with tool_calls is kept (models call tools silently), and
    // the provider-flat tool_calls are lifted under metadata.
    $this->assertCount(1, $output['messages']);
    $this->assertSame('assistant', $output['messages'][0]['role']);
    $this->assertSame($toolCalls, $output['messages'][0]['metadata']['tool_calls']);
  }

  /**
   * A tool-role result is accepted when it carries a tool_call_id.
   *
   * @covers ::process
   */
  public function testProcessAppendsToolResult(): void {
    $params = $this->stubMessageParams([
      'role' => 'tool',
      'content' => 'Found 3 cats',
      'tool_call_id' => 'call_1',
    ]);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertSame('tool', $output['messages'][0]['role']);
    $this->assertSame('call_1', $output['messages'][0]['metadata']['tool_call_id']);
  }

  /**
   * A tool-role turn without a tool_call_id fails loudly (providers reject it).
   *
   * @covers ::process
   */
  public function testProcessRejectsToolMessageWithoutToolCallId(): void {
    $params = $this->stubMessageParams([
      'role' => 'tool',
      'content' => 'orphan result',
    ]);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn([]);

    $this->expectException(InvalidNodeConfigurationException::class);
    $this->processor->process($params->reveal());
  }

  /**
   * An array of messages appends each one in order.
   *
   * @covers ::process
   */
  public function testProcessAppendsArrayOfMessages(): void {
    $params = $this->stubMessageParams([
      ['role' => 'user', 'content' => 'Hi'],
      ['role' => 'assistant', 'content' => 'Hello'],
    ]);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertCount(2, $output['messages']);
    $this->assertSame('Hi', $output['messages'][0]['content']);
    $this->assertSame('Hello', $output['messages'][1]['content']);
  }

  /**
   * An empty-content message that is not a tool call is dropped.
   *
   * @covers ::process
   */
  public function testProcessDropsEmptyNonToolMessage(): void {
    $params = $this->stubMessageParams(['role' => 'user', 'content' => '']);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn([]);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertCount(0, $output['messages']);
  }

  // ---------------------------------------------------------------------------
  // process — pair-safe windowing
  // ---------------------------------------------------------------------------

  /**
   * The window cuts on a user turn so a tool_use/tool_result pair is kept.
   *
   * @covers ::process
   */
  public function testProcessWindowCutsOnUserTurnWithToolMetadata(): void {
    $existing = [
      ['role' => 'assistant', 'content' => '', 'metadata' => ['tool_calls' => [['id' => 'c1']]], 'timestamp' => 1],
      ['role' => 'tool', 'content' => 'res', 'metadata' => ['tool_call_id' => 'c1'], 'timestamp' => 2],
      ['role' => 'user', 'content' => 'next', 'metadata' => [], 'timestamp' => 3],
    ];
    // maxMessages 3, buffer becomes 4 after append; naive boundary index = 1
    // (a tool result) — the window must move the cut forward to the user turn.
    $params = $this->stubMessageParams(['role' => 'assistant', 'content' => 'answer'], maxMessages: 3);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn($existing);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    // Cut on the user turn: [user 'next', assistant 'answer'] — pair not split.
    $this->assertCount(2, $output['messages']);
    $this->assertSame('user', $output['messages'][0]['role']);
    $this->assertSame('answer', $output['messages'][1]['content']);
  }

  /**
   * With no user turn past the boundary the buffer is kept whole, not split.
   *
   * @covers ::process
   */
  public function testProcessWindowKeepsBufferWholeWhenNoUserBoundary(): void {
    $existing = [
      ['role' => 'assistant', 'content' => '', 'metadata' => ['tool_calls' => [['id' => 'c1']]], 'timestamp' => 1],
      ['role' => 'tool', 'content' => 'r1', 'metadata' => ['tool_call_id' => 'c1'], 'timestamp' => 2],
      ['role' => 'assistant', 'content' => '', 'metadata' => ['tool_calls' => [['id' => 'c2']]], 'timestamp' => 3],
    ];
    // Append a tool result; no user turn anywhere — keeping whole avoids
    // orphaning a tool_use from its tool_result.
    $params = $this->stubMessageParams(['role' => 'tool', 'content' => 'r2', 'tool_call_id' => 'c2'], maxMessages: 2);
    $this->memoryManager->get('session', '', 'chat_history', [], 'static')->willReturn($existing);
    $this->memoryManager->set('session', '', 'chat_history', Argument::any(), NULL, 'static')->shouldBeCalled();

    $output = $this->processor->process($params->reveal());

    $this->assertCount(4, $output['messages']);
  }

}
