<?php

declare(strict_types=1);

namespace Drupal\flowdrop\DTO\Reason;

/**
 * Repairs tool-call ↔ tool-result pairing in a replayed conversation.
 *
 * A graph-authored agent normally keeps the conversation well-formed by
 * topology (a node appends the assistant turn, another appends the tool
 * result). But a turn that ERRORS mid-loop can persist an assistant tool_calls
 * turn whose tool never ran, and a retried turn can leave a duplicate tool
 * result. Either makes a provider reject the whole request — "an assistant
 * message with tool_calls must be followed by tool messages responding to each
 * tool_call_id" — which then wedges every future turn on the thread (the broken
 * history replays unchanged each time).
 *
 * This is provider-neutral (it operates on {@see ReasonMessage} values, not any
 * backend's message classes), so every reasoning consumer gets the same repair
 * without re-implementing it. It:
 *  - inserts a SYNTHESIZED "interrupted" tool result right after a dangling
 *    tool_call (requested but never answered) so the history is valid AND the
 *    model sees the call didn't complete — it can retry or re-plan instead of
 *    silently re-issuing the same doomed call. The wording is outcome-agnostic:
 *    a turn can crash after a mutating tool half-ran, so it never claims the
 *    tool "did not run";
 *  - drops an orphan tool result (no matching call to attach to);
 *  - drops a duplicate tool result (same id seen again);
 *  - drops an id-less tool_call (it can't be paired) and an assistant turn left
 *    with neither prose nor a usable call.
 *
 * @api
 */
final class ToolPairing {

  /**
   * Repairs pairing so every tool_call_id pairs up exactly once.
   *
   * @param array<int, \Drupal\flowdrop\DTO\Reason\ReasonMessage> $messages
   *   The replayed conversation.
   *
   * @return array<int, \Drupal\flowdrop\DTO\Reason\ReasonMessage>
   *   The conversation with tool pairing repaired.
   */
  public static function heal(array $messages): array {
    // First pass: which ids did an assistant turn request, and which did a tool
    // turn answer? An id requested but never answered is "dangling".
    $declared = [];
    $resulted = [];
    foreach ($messages as $message) {
      if ($message->getRole() === 'assistant') {
        foreach ($message->getToolCalls() as $call) {
          if (!empty($call['tool_call_id'])) {
            $declared[(string) $call['tool_call_id']] = TRUE;
          }
        }
      }
      elseif ($message->getRole() === 'tool' && $message->getToolCallId() !== NULL) {
        $resulted[$message->getToolCallId()] = TRUE;
      }
    }
    $dangling = array_diff_key($declared, $resulted);

    // Second pass: rebuild. Keep every well-formed call, drop calls with no
    // usable id, answer dangling calls with a synthetic result, and drop orphan
    // / duplicate tool results.
    $answered = [];
    $out = [];
    foreach ($messages as $message) {
      if ($message->getRole() === 'assistant' && $message->getToolCalls() !== []) {
        // Keep only calls carrying a usable id (an id-less call can't be paired
        // and a provider would reject it).
        $kept = [];
        foreach ($message->getToolCalls() as $call) {
          if (!empty($call['tool_call_id'])) {
            $kept[] = $call;
          }
        }
        // A turn with neither prose nor a usable call carries nothing.
        if ($kept === [] && trim($message->getContent()) === '') {
          continue;
        }
        $out[] = new ReasonMessage('assistant', $message->getContent(), $kept);
        // Immediately answer any of this turn's calls that were never answered
        // downstream, so the tool_use block is closed in place.
        foreach ($kept as $call) {
          $id = (string) $call['tool_call_id'];
          if (isset($dangling[$id]) && !isset($answered[$id])) {
            $out[] = new ReasonMessage(
              'tool',
              self::interruptedToolResult((string) ($call['name'] ?? '')),
              [],
              $id,
            );
            $answered[$id] = TRUE;
          }
        }
        continue;
      }
      if ($message->getRole() === 'tool') {
        $id = $message->getToolCallId();
        // Drop orphans (no matching call) and duplicates (already answered).
        if ($id === NULL || !isset($declared[$id]) || isset($answered[$id])) {
          continue;
        }
        $answered[$id] = TRUE;
      }
      $out[] = $message;
    }
    return $out;
  }

  /**
   * The synthetic result that closes a tool call interrupted before returning.
   *
   * Outcome-agnostic by design: a turn can crash after a mutating tool already
   * half-ran, so it must not claim the tool "did not run" — only that the
   * result never came back.
   *
   * @param string $name
   *   The interrupted tool's name, if known.
   *
   * @return string
   *   The synthetic tool-result content.
   */
  private static function interruptedToolResult(string $name): string {
    $label = $name !== '' ? sprintf('`%s`', $name) : 'the tool';
    return sprintf(
      'The previous call to %s was interrupted before returning a result; its outcome is unknown. Do not assume it succeeded — confirm the current state or retry if needed.',
      $label,
    );
  }

}
