<?php

declare(strict_types=1);

namespace Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor;

use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\flowdrop\Attribute\FlowDropNodeProcessor;
use Drupal\flowdrop\Constants\ReservedName;
use Drupal\flowdrop\DTO\ParameterBagInterface;
use Drupal\flowdrop\DTO\Reason\ReasonMessage;
use Drupal\flowdrop\DTO\Reason\ReasonRequest;
use Drupal\flowdrop\DTO\Reason\ToolPairing;
use Drupal\flowdrop\DTO\ValidationResult;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\AbstractFlowDropNodeProcessor;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\ToolsAwareInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\ToolsAwareTrait;
use Drupal\flowdrop\Service\Reasoning\ChatReasonerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Stateless reasoning step for a graph-authored agent loop.
 *
 * One tool-aware LLM inference, NO execution and NO state. The conversation
 * arrives on the `messages` port — typically a memory_read of a conversation
 * buffer that append nodes write (user turn, this node's assistant turn, tool
 * results are each appended by a node on the canvas, so the buffer is
 * well-formed by topology). Given that conversation plus the tools wired to
 * this node (native `tool_availability` edges, resolved by FlowDrop's
 * ToolProjector and injected via setTools()), it asks the backend what the
 * model wants to do next — a set of `tool_calls` or a final `text` answer. It
 * never runs a tool; a {@see ToolInvoke} node executes the chosen calls.
 *
 * This is the only reasoning PHP in the loop: the loop itself (branch on
 * `has_tool_calls`, dispatch to a tool-invoke node, append the result, iterate)
 * is authored as a graph of primitives, so the iteration cap, quality gates and
 * guardrails stay editor-configurable, and every tool call is a node execution
 * on the shared session (auditable) — not an off-graph `$plugin->execute()`.
 *
 * PROVIDER-NEUTRAL: it depends only on {@see ChatReasonerInterface} and
 * FlowDrop's own DTOs, so core carries no `drupal/ai` dependency; a backend
 * module supplies the concrete `flowdrop.chat_reasoner`.
 *
 * EXPOSURE = TOPOLOGY: the model is shown exactly the tools wired into this
 * node — `getTools()->getDefinitions()` — so there is no PHP allow-list and the
 * tool manifest can't drift from the wired capabilities' real signatures.
 *
 * The `loop_back` input is a loopback port (excluded from cycle detection): the
 * loop's iterate edge connects the loop body's output back into it so the
 * canvas does not reject the cycle.
 *
 * @see \Drupal\flowdrop\Service\Reasoning\ChatReasonerInterface
 * @see \Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\ToolInvoke
 * @see \Drupal\flowdrop\Plugin\FlowDropNodeProcessor\ToolsAwareInterface
 */
#[FlowDropNodeProcessor(
  id: "reason",
  label: new TranslatableMarkup("Reason (agent step)"),
  description: "One tool-aware LLM inference: emits the next tool_calls or a final answer (executes nothing)",
  version: "1.0.0",
)]
class Reason extends AbstractFlowDropNodeProcessor implements ToolsAwareInterface {

  use ToolsAwareTrait;

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    protected ChatReasonerInterface $reasoner,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition,
  ): static {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('flowdrop.chat_reasoner'),
    );
  }

  /**
   * {@inheritdoc}
   */
  public function process(ParameterBagInterface $params): array {
    // The conversation arrives on the `messages` data port — typically a
    // memory_read of the visible conversation buffer (append nodes are the
    // writers). The loop re-reads the buffer on every loop-back iteration, so
    // this node carries NO state of its own: by the time it runs, the buffer
    // already ends with the current user turn (and, mid-loop, tool results).
    // Topology keeps the buffer well-formed on every NORMAL path; ToolPairing
    // additionally heals tool pairing so a turn that crashed mid-loop — leaving
    // a tool_calls turn whose result never landed — can't wedge every future
    // turn on the thread (the dangling call is answered with a synthetic
    // "interrupted" result so the model can re-plan).
    $messages = [];
    foreach ($params->getArray('messages', []) as $raw) {
      if (is_array($raw)) {
        $messages[] = ReasonMessage::fromArray($raw);
      }
    }
    $messages = ToolPairing::heal($messages);
    if ($messages === []) {
      throw new \RuntimeException('Reason node has no messages to reason over');
    }

    // Provider invariant + loop idempotency: a chat request must NOT end on an
    // assistant turn — providers treat a trailing assistant message as a
    // prefill ("the conversation must end with a user message"). A legitimate
    // reason step always runs after a fresh user turn (ends with `user`) or a
    // tool result on the loop-back (ends with `tool`), so a buffer that — after
    // healing — still ends with a COMPLETED assistant turn means there is
    // nothing new to reason over: the loop already produced its answer and this
    // is a spurious re-entry (e.g. an interrupt-resume re-firing the loop,
    // which otherwise both crashes the turn here AND re-issues the previous
    // tool call — a duplicate side effect). Make it a no-op: terminate, append
    // nothing.
    if (end($messages)->getRole() === 'assistant') {
      return [
        'tool_calls' => [],
        'text' => '',
        'has_tool_calls' => FALSE,
        'assistant_message' => [],
      ];
    }

    // Tools come from the native tool_availability wiring, not a data port:
    // ToolProjector resolves this node's wired tool nodes and the runtime
    // injects them via setTools() before process() runs.
    $request = new ReasonRequest(
      $messages,
      $this->getTools()->getDefinitions(),
      (string) $params->get('model', ''),
      (string) $params->get('operation_type', '') ?: 'chat',
      (float) $params->get('temperature', 0),
      (int) $params->get('maxTokens', 1024),
      (string) $params->get('systemPrompt', ''),
    );

    $result = $this->reasoner->reason($request);

    return [
      'tool_calls' => $result->getToolCalls(),
      'text' => $result->getText(),
      'has_tool_calls' => $result->hasToolCalls(),
      // The assistant turn — wire it to a conversation-append node so it lands
      // in the visible buffer (this node persists nothing itself).
      'assistant_message' => $result->getAssistantMessage(),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function validateParams(array $params): ValidationResult {
    // The `messages` port may legitimately be empty on exotic wirings; the
    // process() guard throws a clear error when there is nothing to reason
    // over, so nothing is schema-required here.
    return ValidationResult::success();
  }

  /**
   * {@inheritdoc}
   */
  public function getParameterSchema(): array {
    // Populate the model / role fields from the backend's advertised choices,
    // so this node needs no backend service of its own. With the null reasoner
    // (no AI installed) the choices are empty and the fields degrade to
    // free-text — still valid.
    $choices = $this->reasoner->getModelChoices('chat');
    $models = $choices->getModels();
    $default_model = $choices->getDefaultModel();
    if (($default_model === '' || $default_model === NULL) && $models !== []) {
      $default_model = $models[0];
    }
    $operation_type_options = $choices->getOperationTypeOptions();
    $operation_type_ids = array_column($operation_type_options, 'value');

    return [
      'type' => 'object',
      'properties' => [
        'messages' => [
          'type' => 'array',
          'title' => 'Messages',
          'description' => 'The conversation to reason over (wire a memory_read of the conversation buffer): items with {role, content}; tool results add tool_call_id, assistant turns may carry tool_calls.',
          'default' => [],
          'required' => FALSE,
        ],
        // The tool input port (ReservedName::PORT_TOOL). This node is
        // ToolsAware, but a tool handle only renders if the port is DECLARED:
        // name = the reserved 'tool' constant (drives runtime classification
        // via the {nodeId}-input-tool handle), dataType = 'tool' (drives the
        // editor's amber handle + connection validation). A tool node wired in
        // here (tool.output → reason.input-tool) is exposed to the model —
        // incoming tool edges are the allow-list.
        ReservedName::PORT_TOOL => [
          'type' => 'tool',
          'title' => 'Tools',
          'description' => 'Tools wired to this node. Wiring a tool here exposes it; the edges are the allow-list.',
          'required' => FALSE,
        ],
        'loop_back' => [
          'type' => 'any',
          'title' => 'Loop Back',
          'description' => 'Loopback trigger — connect the loop body output here to re-run reasoning.',
          'required' => FALSE,
          'is_loopback' => TRUE,
          'loopback_metadata' => [
            'edge_style' => 'dotted',
            'skip_cycle_detection' => TRUE,
            'label' => 'Loop Back',
          ],
        ],
        'operation_type' => [
          'type' => 'string',
          'title' => 'Model Role',
          'description' => "Which model role / operation type resolves this node's model when no explicit model is set.",
          'default' => 'chat',
          'enum' => $operation_type_ids,
          'options' => $operation_type_options,
        ],
        'model' => [
          'type' => 'string',
          'title' => 'Model',
          'description' => 'Optional explicit model override (wins over the role). Defaults to the role-resolved model.',
          'default' => $default_model,
          // The empty string is the "no override — resolve via the Model Role"
          // sentinel. It must be a valid enum member or ParameterResolver
          // rejects the stored '' before the node runs.
          'enum' => array_merge([''], $models),
        ],
        'temperature' => [
          'type' => 'number',
          'title' => 'Temperature',
          'description' => 'Sampling temperature (0 = deterministic, best for routing).',
          'default' => 0,
          'minimum' => 0,
          'maximum' => 2,
        ],
        'maxTokens' => [
          'type' => 'integer',
          'title' => 'Max Tokens',
          'description' => 'Maximum tokens to generate.',
          'default' => 1024,
          'minimum' => 1,
        ],
        'systemPrompt' => [
          'type' => 'string',
          'title' => 'System Prompt',
          'description' => 'Instructions for the agent (its role, when to use which tool, when to stop).',
          'default' => '',
          'format' => 'multiline',
        ],
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getOutputSchema(): array {
    return [
      'type' => 'object',
      'properties' => [
        'tool_calls' => [
          'type' => 'array',
          'description' => 'Tool calls the model requested: [{name, args, tool_call_id}]. Empty when it answered directly.',
        ],
        'text' => [
          'type' => 'string',
          'description' => 'The assistant prose (a final answer, or commentary alongside tool calls).',
        ],
        'has_tool_calls' => [
          'type' => 'boolean',
          'description' => 'TRUE if the model wants to call a tool — branch the loop on this.',
        ],
        'assistant_message' => [
          'type' => 'json',
          'description' => 'The assistant turn ({role, content, tool_calls}) — wire to a conversation-append node to persist it.',
        ],
      ],
    ];
  }

}
