<?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_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);
    $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;
  }

  // ---------------------------------------------------------------------------
  // 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'] as $role) {
      $result = $this->processor->validateParams(['role' => $role, 'content' => 'msg']);
      $this->assertTrue($result->isValid(), "Expected valid for role: $role");
    }
  }

  // ---------------------------------------------------------------------------
  // 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);
  }

}
