<?php

declare(strict_types=1);

namespace Drupal\flowdrop_runtime\Service\Runtime;

use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop\DTO\Tool\ToolBinding;
use Drupal\flowdrop\DTO\Tool\ToolCollection;
use Drupal\flowdrop\DTO\Tool\ToolCollectionInterface;
use Drupal\flowdrop\DTO\Tool\ToolName;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop_node_type\FlowDropNodeTypeInterface;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface;
use Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph;
use Drupal\flowdrop_runtime\DTO\Compiler\DependencyNode;
use Drupal\flowdrop_runtime\DTO\Runtime\NodeExecutionContext;
use Drupal\flowdrop_runtime\Service\Tool\ToolGraphFlattenerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * Default {@see ToolProjectorInterface} implementation.
 *
 * Resolves a consumer node's `tool_availability` targets into tool bindings,
 * reading each tool node's label (for the model-facing name and description)
 * and its processor's parameter schema, all wired to a single per-consumer
 * {@see ScopedToolInvoker}.
 *
 * Tool parents are flattened through passthrough nodes (ToolBoxes) first, so
 * the projected collection and the invoker allow-list are both the flattened
 * leaf set — a single, graph-derived source of truth.
 */
final class ToolProjector implements ToolProjectorInterface {

  /**
   * Constructs a ToolProjector.
   *
   * @param \Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager $nodeProcessorManager
   *   The node processor plugin manager.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entity type manager.
   * @param \Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeServiceInterface $nodeRuntime
   *   The node runtime, passed to the scoped invoker for tool execution.
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger.
   * @param \Drupal\flowdrop_runtime\Service\Tool\ToolGraphFlattenerInterface $toolGraphFlattener
   *   The tool-graph flattener resolving passthrough (ToolBox) wiring.
   * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface|null $eventDispatcher
   *   The event dispatcher the scoped invoker uses for JobCompletedEvent when
   *   tool calls are recorded as pipeline jobs.
   */
  public function __construct(
    private readonly FlowDropNodeProcessorPluginManager $nodeProcessorManager,
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly NodeRuntimeServiceInterface $nodeRuntime,
    private readonly LoggerInterface $logger,
    private readonly ToolGraphFlattenerInterface $toolGraphFlattener,
    private readonly ?EventDispatcherInterface $eventDispatcher = NULL,
  ) {}

  /**
   * {@inheritdoc}
   */
  public function project(
    string $consumerNodeId,
    DependencyGraph $graph,
    NodeExecutionContext $context,
    string $executionId,
    ?FlowDropPipelineRuntimeInterface $pipeline = NULL,
  ): ToolCollectionInterface {
    // A tool connection is drawn tool.output -> consumer.input, so the tools
    // wired to a consumer are its incoming tool_availability edges (parents),
    // flattened through any passthrough nodes (ToolBoxes) to the leaf tools.
    // Throws ToolWiringException on a passthrough cycle — better to abort than
    // run the consumer with a silently incomplete tool set.
    $toolNodeIds = $this->toolGraphFlattener->flattenLeafTools($consumerNodeId, $graph);
    if (empty($toolNodeIds)) {
      return new ToolCollection();
    }

    $invoker = new ScopedToolInvoker(
      $toolNodeIds,
      $graph,
      $context,
      $executionId,
      $this->nodeProcessorManager,
      $this->entityTypeManager,
      $this->nodeRuntime,
      $this->logger,
      $consumerNodeId,
      $pipeline,
      $this->eventDispatcher,
    );

    $bindings = [];
    foreach ($toolNodeIds as $toolNodeId) {
      $node = $graph->getNode($toolNodeId);
      if ($node === NULL) {
        $this->logger->warning("Tool node @node wired to @consumer was not found; skipping.", [
          "@node" => $toolNodeId,
          "@consumer" => $consumerNodeId,
        ]);
        continue;
      }

      $label = $node->getLabel() !== "" ? $node->getLabel() : $toolNodeId;
      [$pluginDescription, $schema] = $this->describe($node);

      $description = implode(" — ", array_filter([$label, $pluginDescription]));

      $bindings[] = new ToolBinding(
        ToolName::fromLabel($label),
        $toolNodeId,
        $description,
        $schema,
        sprintf("%s (%s)", $label, $toolNodeId),
        $invoker,
      );
    }

    return new ToolCollection($bindings);
  }

  /**
   * Reads a tool node's plugin description and model-facing parameter schema.
   *
   * The schema is projected down to the params the author left open (see
   * {@see ToolParameterScope}): params the author pinned in the node's config
   * are removed, so the model is neither offered them nor able to override the
   * configured value.
   *
   * @param \Drupal\flowdrop_runtime\DTO\Compiler\DependencyNode $node
   *   The tool node being projected.
   *
   * @return array{0: string, 1: array<string, mixed>}
   *   The plugin description and projected parameter schema (empty defaults on
   *   failure).
   */
  private function describe(DependencyNode $node): array {
    $entity = $this->entityTypeManager
      ->getStorage("flowdrop_node_type")
      ->load($node->getNodeTypeId());
    if (!$entity instanceof FlowDropNodeTypeInterface) {
      return ["", []];
    }

    try {
      $processor = $this->nodeProcessorManager->createInstance($entity->getExecutorPlugin());
      // createInstance() returns a processor or throws in production; a null
      // only arises from a misbehaving or mocked plugin manager. Normalize it
      // into the same not-found signal so it is logged and degraded through the
      // single catch below, rather than fataling on null->getParameterSchema().
      if ($processor === NULL) {
        throw new PluginNotFoundException($entity->getExecutorPlugin());
      }
      $parameterSchema = $processor->getParameterSchema();
      $fillable = ToolParameterScope::fillableNames($entity->getParameters(), $node->getConfig(), $parameterSchema);
      $schema = ToolParameterScope::projectSchema($parameterSchema, $fillable);
      // Normalize the relaxed internal schema dialect into standard JSON Schema
      // before it becomes a tool's model-facing input_schema, so providers
      // accept it and a consumer need not strip it (per-property `required`
      // booleans, non-standard `mixed`/`any` types).
      $schema = ToolParameterScope::normalizeForModel($schema);
      return [$processor->getDescription(), $schema];
    }
    catch (\Exception $e) {
      $this->logger->warning("Could not describe tool node type @type: @msg", [
        "@type" => $node->getNodeTypeId(),
        "@msg" => $e->getMessage(),
      ]);
      return ["", []];
    }
  }

}
