<?php

declare(strict_types=1);

namespace Drupal\flowdrop_runtime\Service\Runtime;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop\DTO\Tool\ToolResult;
use Drupal\flowdrop\DTO\Tool\ToolResultInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop\Service\Tool\ToolInvokerInterface;
use Drupal\flowdrop_job\Constants\JobStatus;
use Drupal\flowdrop_job\FlowDropJobInterface;
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\DTO\Runtime\NodeExecutionResult;
use Drupal\flowdrop_runtime\Event\JobCompletedEvent;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * A {@see ToolInvokerInterface} scoped to a single consumer node.
 *
 * Created per node execution by {@see ToolProjector}, capturing the consumer's
 * allow-list (its `tool_availability` targets) and execution context. This is
 * the security boundary: every call re-checks the requested node against the
 * allow-list, so a processor can never reach a node the author did not wire as
 * one of its tools — even if it forges a node ID.
 *
 * Invocation runs the tool node through the single node-execution path
 * ({@see NodeRuntimeServiceInterface::executeNode()}), reusing its logging,
 * status, events and interrupt handling (Model A: inline, synchronous).
 *
 * When constructed with the live pipeline, every invocation is additionally
 * recorded as a NORMAL pipeline job item: a `flowdrop_job` entity created
 * before execution (running) and finalized after (completed/failed, with
 * input args, output data and timing), appended to the pipeline, with a
 * {@see JobCompletedEvent} dispatched like any orchestrated node — so tool
 * calls show up in the pipeline's job trail and to event subscribers exactly
 * as first-class node executions. Recording is best-effort: a bookkeeping
 * failure is logged and never breaks the tool call itself.
 */
final class ScopedToolInvoker implements ToolInvokerInterface {

  /**
   * Constructs a ScopedToolInvoker.
   *
   * @param array<int, string> $allowedNodeIds
   *   The tool node IDs this consumer is authorized to call.
   * @param \Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph $graph
   *   The dependency graph, used to resolve a tool node's type and config.
   * @param \Drupal\flowdrop_runtime\DTO\Runtime\NodeExecutionContext $context
   *   The execution context to run tool nodes under.
   * @param string $executionId
   *   The execution ID.
   * @param \Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager $nodeProcessorManager
   *   The node processor plugin manager.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entity type manager (for loading node type entities).
   * @param \Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeServiceInterface $nodeRuntime
   *   The node runtime used to execute tool nodes.
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger.
   * @param string $consumerNodeId
   *   The consumer node this invoker is scoped to (recorded on tool jobs).
   * @param \Drupal\flowdrop_pipeline\Entity\FlowDropPipelineRuntimeInterface|null $pipeline
   *   The pipeline of the current run, or NULL to skip job recording. Tool
   *   jobs are appended to it and persisted immediately, so the orchestrator's
   *   wave-boundary reloads carry the references forward.
   * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface|null $eventDispatcher
   *   The event dispatcher for JobCompletedEvent, or NULL to skip dispatching.
   */
  public function __construct(
    private readonly array $allowedNodeIds,
    private readonly DependencyGraph $graph,
    private readonly NodeExecutionContext $context,
    private readonly string $executionId,
    private readonly FlowDropNodeProcessorPluginManager $nodeProcessorManager,
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly NodeRuntimeServiceInterface $nodeRuntime,
    private readonly LoggerInterface $logger,
    private readonly string $consumerNodeId = '',
    private readonly ?FlowDropPipelineRuntimeInterface $pipeline = NULL,
    private readonly ?EventDispatcherInterface $eventDispatcher = NULL,
  ) {}

  /**
   * {@inheritdoc}
   */
  public function invokeTool(string $nodeId, array $args): ToolResultInterface {
    // Authorization (integrity violation, not a recoverable error): the node
    // must be one of this consumer's wired tools.
    if (!in_array($nodeId, $this->allowedNodeIds, TRUE)) {
      $this->logger->error("Unauthorized tool call: node @node is not in the allow-list for execution @exec", [
        "@node" => $nodeId,
        "@exec" => $this->executionId,
      ]);
      throw new \RuntimeException("Unauthorized tool call: '{$nodeId}' is not an available tool.");
    }

    $node = $this->graph->getNode($nodeId);
    if ($node === NULL) {
      throw new \RuntimeException("Tool node '{$nodeId}' was not found in the graph.");
    }
    $nodeType = $node->getNodeTypeId();

    // Drop any args for params the author did not expose: the model must not
    // be able to override a not-exposed param (e.g. an HTTP node's URL). This
    // mirrors the schema projection in ToolProjector and is the enforcing
    // half — the model can ignore the advertised schema, so the non-fillable
    // args are stripped here before they reach the resolver, where runtime
    // inputs would otherwise outrank the author's config.
    $entity = $this->entityTypeManager
      ->getStorage("flowdrop_node_type")
      ->load($nodeType);
    if ($entity instanceof FlowDropNodeTypeInterface) {
      // Compute the fillable set from the SAME inputs ToolProjector used (the
      // processor's per-param `x-exposed-by-default`), so the schema the model
      // was shown and the args we accept here cannot diverge. If the processor
      // cannot be described, fail closed: nothing is fillable, so every
      // model-supplied arg is dropped and the author's config wins outright.
      // This mirrors ToolProjector, which advertises an empty schema in that
      // case — the safe direction for an enforcement boundary is to allow
      // less, never more, than was advertised.
      try {
        $parameterSchema = $this->nodeProcessorManager
          ->createInstance($entity->getExecutorPlugin())
          ->getParameterSchema();
        $fillable = ToolParameterScope::fillableNames($entity->getParameters(), $node->getConfig(), $parameterSchema);
      }
      catch (\Exception) {
        $fillable = [];
      }
      $args = ToolParameterScope::filterArgs($args, $fillable);
    }

    // Argument validation (recoverable): surface a tool-error the model can act
    // on, rather than executing with invalid input. Validate the EFFECTIVE
    // params — the model's fillable args overlaid on the author's config — so a
    // required param the author pinned is not misread as missing. This is the
    // same set the node will actually execute with.
    $validation = $this->validateArgs($nodeType, $args + $node->getConfig());
    if ($validation !== NULL) {
      return ToolResult::error($validation);
    }

    // Record the call as a normal pipeline job item (running) before it runs,
    // so the trail is honest even if execution throws.
    $job = $this->createToolJob($node, $nodeId, $nodeType, $args);

    try {
      $result = $this->nodeRuntime->executeNode(
        $this->executionId,
        $nodeId,
        $nodeType,
        $args,
        $node->getConfig(),
        $this->context,
      );
    }
    catch (\Exception $e) {
      $this->logger->warning("Tool node @node failed: @msg", [
        "@node" => $nodeId,
        "@msg" => $e->getMessage(),
      ]);
      $this->finalizeToolJob($job, NULL, $e->getMessage());
      return ToolResult::error($e->getMessage());
    }

    $output = $result->getOutput();
    if ($output->isSuccess()) {
      $this->finalizeToolJob($job, $result, NULL);
      return ToolResult::success($output->getAllOutput());
    }
    $error = $output->getErrorMessage() ?? "Tool execution failed.";
    $this->finalizeToolJob($job, $result, $error);
    return ToolResult::error($error);
  }

  /**
   * Creates a running job recording this tool call, when recording is on.
   *
   * The job is appended to the pipeline AND persisted immediately: the
   * orchestrator reloads the pipeline between waves (and right before its
   * end-of-run save), so an in-memory-only append on the current instance
   * would be dropped at the next reload — persisting here makes every later
   * reload carry the reference forward.
   *
   * @return \Drupal\flowdrop_job\FlowDropJobInterface|null
   *   The running job, or NULL when recording is off or bookkeeping failed.
   */
  private function createToolJob(DependencyNode $node, string $nodeId, string $nodeType, array $args): ?FlowDropJobInterface {
    if ($this->pipeline === NULL) {
      return NULL;
    }
    try {
      $storage = $this->entityTypeManager->getStorage("flowdrop_job");
      /** @var \Drupal\flowdrop_job\FlowDropJobInterface $job */
      $job = $storage->create([
        "label" => $node->getLabel() !== "" ? $node->getLabel() : $nodeId,
        "bundle" => "default",
        "node_type_id" => $nodeType,
        "metadata" => json_encode([
          "node_id" => $nodeId,
          "node_type_id" => $nodeType,
          "config" => $node->getConfig(),
          // Marks this job as a tool call run on behalf of a consumer node
          // (vs. a graph-scheduled node execution).
          "tool_invocation" => TRUE,
          "invoked_by" => $this->consumerNodeId,
        ]),
        "status" => JobStatus::RUNNING,
        "input_data" => json_encode($args),
        "output_data" => json_encode([]),
        // Tool calls are model-recoverable; the runtime never retries them.
        "max_retries" => 0,
      ]);
      $job->markAsStarted();
      $storage->save($job);
      $this->pipeline->addJob($job);
      // Persist the reference now — the orchestrator reloads the pipeline
      // between waves, so an unsaved append would be lost at the next reload.
      if ($this->pipeline instanceof EntityInterface) {
        $this->entityTypeManager
          ->getStorage($this->pipeline->getEntityTypeId())
          ->save($this->pipeline);
      }
      return $job;
    }
    catch (\Throwable $e) {
      $this->logger->warning("Could not record tool call @node as a pipeline job: @msg", [
        "@node" => $nodeId,
        "@msg" => $e->getMessage(),
      ]);
      return NULL;
    }
  }

  /**
   * Finalizes a recorded tool job and dispatches JobCompletedEvent.
   *
   * @param \Drupal\flowdrop_job\FlowDropJobInterface|null $job
   *   The running job from {@see createToolJob()}, or NULL (recording off).
   * @param \Drupal\flowdrop_runtime\DTO\Runtime\NodeExecutionResult|null $result
   *   The execution result, or NULL when execution threw.
   * @param string|null $error
   *   The error message, or NULL on success.
   */
  private function finalizeToolJob(?FlowDropJobInterface $job, ?NodeExecutionResult $result, ?string $error): void {
    if ($job === NULL || $this->pipeline === NULL) {
      return;
    }
    try {
      if ($error === NULL) {
        $job->markAsCompleted($result?->getOutput()->toArray() ?? []);
      }
      else {
        $job->markAsFailed($error);
      }
      if ($result !== NULL) {
        $metadata = $job->getMetadata();
        $metadata["execution_time_us"] = (int) round($result->getExecutionTime() * 1000000);
        $job->setMetadata($metadata);
      }
      $this->entityTypeManager->getStorage("flowdrop_job")->save($job);

      // Same contract as orchestrated nodes: one JobCompletedEvent per
      // finished job, success or failure.
      $this->eventDispatcher?->dispatch(
        new JobCompletedEvent($job, $this->pipeline->getExecutionContext(), $this->executionId),
        JobCompletedEvent::NAME,
      );
    }
    catch (\Throwable $e) {
      $this->logger->warning("Could not finalize the tool-call job for @node: @msg", [
        "@node" => $job->getNodeId() ?: $job->label(),
        "@msg" => $e->getMessage(),
      ]);
    }
  }

  /**
   * Validates arguments against a tool node's parameter schema.
   *
   * @param string $nodeType
   *   The node type ID.
   * @param array<string, mixed> $args
   *   The arguments to validate.
   *
   * @return string|null
   *   An error message if validation failed, or NULL if the args are valid (or
   *   the processor could not be loaded, in which case execution proceeds and
   *   any failure surfaces from the runtime).
   */
  private function validateArgs(string $nodeType, array $args): ?string {
    $entity = $this->entityTypeManager
      ->getStorage("flowdrop_node_type")
      ->load($nodeType);
    if (!$entity instanceof FlowDropNodeTypeInterface) {
      return NULL;
    }

    $processor = $this->nodeProcessorManager->createInstance($entity->getExecutorPlugin());
    $validation = $processor->validateParams($args);
    if ($validation->isValid()) {
      return NULL;
    }

    return implode("; ", $validation->getErrorMessages());
  }

}
