<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop\Unit\DTO\Reason;

use Drupal\flowdrop\DTO\Reason\ReasonMessage;
use Drupal\flowdrop\DTO\Reason\ToolPairing;
use Drupal\Tests\UnitTestCase;

/**
 * Tests the ToolPairing conversation repair.
 *
 * @coversDefaultClass \Drupal\flowdrop\DTO\Reason\ToolPairing
 * @group flowdrop
 */
final class ToolPairingTest extends UnitTestCase {

  /**
   * Renders a healed conversation back to plain arrays for assertions.
   *
   * @param array<int, \Drupal\flowdrop\DTO\Reason\ReasonMessage> $messages
   *   The healed messages.
   *
   * @return array<int, array<string, mixed>>
   *   The plain-array form.
   */
  private function toArrays(array $messages): array {
    return array_map(static fn (ReasonMessage $m): array => $m->toArray(), $messages);
  }

  /**
   * @covers ::heal
   *
   * A well-formed conversation is returned unchanged.
   */
  public function testWellFormedIsUnchanged(): void {
    $messages = [
      new ReasonMessage('user', 'Do the thing'),
      new ReasonMessage('assistant', '', [['name' => 't', 'args' => [], 'tool_call_id' => 'c1']]),
      new ReasonMessage('tool', 'done', [], 'c1'),
    ];
    $healed = $this->toArrays(ToolPairing::heal($messages));

    $this->assertCount(3, $healed);
    $this->assertSame('c1', $healed[1]['tool_calls'][0]['tool_call_id']);
    $this->assertSame('c1', $healed[2]['tool_call_id']);
  }

  /**
   * @covers ::heal
   *
   * A dangling tool_call (requested, never answered) gets a synthetic result
   * inserted right after its turn, so the history is valid.
   */
  public function testDanglingCallGetsSyntheticResult(): void {
    $messages = [
      new ReasonMessage('user', 'Do the thing'),
      new ReasonMessage('assistant', '', [['name' => 'writer', 'args' => [], 'tool_call_id' => 'c1']]),
    ];
    $healed = $this->toArrays(ToolPairing::heal($messages));

    $this->assertCount(3, $healed);
    $this->assertSame('tool', $healed[2]['role']);
    $this->assertSame('c1', $healed[2]['tool_call_id']);
    $this->assertStringContainsString('interrupted', $healed[2]['content']);
    $this->assertStringContainsString('writer', $healed[2]['content']);
  }

  /**
   * @covers ::heal
   *
   * An orphan tool result (no matching call) is dropped.
   */
  public function testOrphanToolResultIsDropped(): void {
    $messages = [
      new ReasonMessage('user', 'hi'),
      new ReasonMessage('tool', 'stray', [], 'ghost'),
    ];
    $healed = $this->toArrays(ToolPairing::heal($messages));

    $this->assertCount(1, $healed);
    $this->assertSame('user', $healed[0]['role']);
  }

  /**
   * @covers ::heal
   *
   * A duplicate tool result (same id seen again) is dropped.
   */
  public function testDuplicateToolResultIsDropped(): void {
    $messages = [
      new ReasonMessage('assistant', '', [['name' => 't', 'args' => [], 'tool_call_id' => 'c1']]),
      new ReasonMessage('tool', 'first', [], 'c1'),
      new ReasonMessage('tool', 'second', [], 'c1'),
    ];
    $healed = $this->toArrays(ToolPairing::heal($messages));

    $this->assertCount(2, $healed);
    $this->assertSame('first', $healed[1]['content']);
  }

  /**
   * @covers ::heal
   *
   * An id-less tool_call can't be paired and is dropped; a turn left with
   * neither prose nor a usable call is dropped entirely.
   */
  public function testIdlessCallIsDropped(): void {
    $messages = [
      new ReasonMessage('user', 'hi'),
      new ReasonMessage('assistant', '', [['name' => 't', 'args' => []]]),
    ];
    $healed = $this->toArrays(ToolPairing::heal($messages));

    $this->assertCount(1, $healed);
    $this->assertSame('user', $healed[0]['role']);
  }

}
