<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_node_processor\Kernel;

use Drupal\flowdrop\DTO\ParameterBag;
use Drupal\flowdrop\DTO\Reason\ModelChoices;
use Drupal\flowdrop\DTO\Reason\ReasonRequest;
use Drupal\flowdrop\DTO\Reason\ReasonResult;
use Drupal\flowdrop\DTO\Tool\ToolCollection;
use Drupal\flowdrop\Service\Reasoning\ChatReasonerInterface;
use Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\Reason;
use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests the Reason node — one tool-aware inference against a fake backend.
 *
 * No live provider: a fake ChatReasonerInterface captures the ReasonRequest and
 * returns a canned ReasonResult, so we assert the node's own contract — buffer
 * healing before inference, the trailing-assistant no-op, and the tool-calls
 * passthrough — without any AI dependency.
 *
 * @coversDefaultClass \Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\Reason
 * @group flowdrop_node_processor
 */
#[RunTestsInSeparateProcesses]
final class ReasonTest extends KernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = ['flowdrop', 'flowdrop_node_processor'];

  /**
   * A fake reasoner that records requests and returns a canned result.
   */
  private function fakeReasoner(ReasonResult $result, array &$captured): ChatReasonerInterface {
    return new class($result, $captured) implements ChatReasonerInterface {

      public function __construct(
        private ReasonResult $result,
        private array &$captured,
      ) {}

      /**
       * {@inheritdoc}
       */
      public function reason(ReasonRequest $request): ReasonResult {
        $this->captured['calls'] = ($this->captured['calls'] ?? 0) + 1;
        $this->captured['request'] = $request;
        return $this->result;
      }

      /**
       * {@inheritdoc}
       */
      public function getModelChoices(string $operationType = 'chat'): ModelChoices {
        return new ModelChoices();
      }

    };
  }

  /**
   * Builds a Reason node bound to the given fake reasoner.
   */
  private function reasonNode(ChatReasonerInterface $reasoner): Reason {
    $node = new Reason([], 'flowdrop_node_processor:reason', [], $reasoner);
    $node->setTools(new ToolCollection([]));
    return $node;
  }

  /**
   * @covers ::process
   *
   * The model's tool calls pass through to the output ports, and the assistant
   * turn is surfaced for the append node.
   */
  public function testToolCallsPassThrough(): void {
    $captured = [];
    $calls = [['name' => 'writer', 'args' => ['x' => 1], 'tool_call_id' => 'c1']];
    $node = $this->reasonNode($this->fakeReasoner(new ReasonResult('thinking', $calls), $captured));

    $result = $node->process(new ParameterBag([
      'messages' => [['role' => 'user', 'content' => 'go']],
    ]));

    $this->assertTrue($result['has_tool_calls']);
    $this->assertSame($calls, $result['tool_calls']);
    $this->assertSame('thinking', $result['text']);
    $this->assertSame('assistant', $result['assistant_message']['role']);
    $this->assertSame($calls, $result['assistant_message']['tool_calls']);
  }

  /**
   * @covers ::process
   *
   * A buffer that (after healing) ends on a completed assistant turn is a
   * spurious re-entry: the node is a no-op and never calls the backend.
   */
  public function testTrailingAssistantIsNoOp(): void {
    $captured = [];
    $node = $this->reasonNode($this->fakeReasoner(new ReasonResult('unused'), $captured));

    $result = $node->process(new ParameterBag([
      'messages' => [
        ['role' => 'user', 'content' => 'go'],
        ['role' => 'assistant', 'content' => 'here is your answer'],
      ],
    ]));

    $this->assertFalse($result['has_tool_calls']);
    $this->assertSame('', $result['text']);
    $this->assertSame([], $result['assistant_message']);
    // The backend must not have been consulted.
    $this->assertArrayNotHasKey('calls', $captured);
  }

  /**
   * @covers ::process
   *
   * Tool pairing is healed before inference: a dangling tool_call is answered
   * with a synthetic result, so the request the backend sees ends on a tool
   * turn (not the dangling assistant turn).
   */
  public function testHealingAppliedBeforeInference(): void {
    $captured = [];
    $node = $this->reasonNode($this->fakeReasoner(new ReasonResult('ok'), $captured));

    $node->process(new ParameterBag([
      'messages' => [
        ['role' => 'user', 'content' => 'go'],
        [
          'role' => 'assistant',
          'content' => '',
          'tool_calls' => [
            ['name' => 'writer', 'args' => [], 'tool_call_id' => 'c1'],
          ],
        ],
      ],
    ]));

    $this->assertSame(1, $captured['calls']);
    $request = $captured['request'];
    $this->assertInstanceOf(ReasonRequest::class, $request);
    $messages = $request->getMessages();
    // user, assistant(tool_calls), synthetic tool result.
    $this->assertCount(3, $messages);
    $this->assertSame('tool', end($messages)->getRole());
    $this->assertSame('c1', end($messages)->getToolCallId());
  }

  /**
   * @covers ::process
   *
   * An empty conversation throws a clear error.
   */
  public function testEmptyMessagesThrows(): void {
    $captured = [];
    $node = $this->reasonNode($this->fakeReasoner(new ReasonResult('x'), $captured));

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage('no messages to reason over');
    $node->process(new ParameterBag(['messages' => []]));
  }

}
