<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_runtime\Unit\Service\Runtime;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Tests\UnitTestCase;
use Drupal\flowdrop\DTO\Output;
use Drupal\flowdrop\DTO\ValidationResult;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\FlowDropNodeProcessorInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
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 Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeServiceInterface;
use Drupal\flowdrop_runtime\Service\Runtime\ScopedToolInvoker;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * Tests the ScopedToolInvoker — the tool-call security boundary.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\Runtime\ScopedToolInvoker
 * @group flowdrop_runtime
 */
class ScopedToolInvokerTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The node runtime mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private $nodeRuntime;

  /**
   * The plugin manager mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private $pluginManager;

  /**
   * The entity type manager mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private $entityTypeManager;

  /**
   * The logger mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private $logger;

  /**
   * Builds an invoker scoped to the given allow-list over one tool node.
   *
   * Pass $pipeline (+ $dispatcher) to exercise tool-call job recording.
   */
  private function invoker(
    array $allowedNodeIds,
    ?FlowDropPipelineRuntimeInterface $pipeline = NULL,
    ?EventDispatcherInterface $dispatcher = NULL,
    string $consumerNodeId = '',
  ): ScopedToolInvoker {
    $graph = new DependencyGraph(
      ["search_1" => new DependencyNode("search_1", "search_type", "Web Search", ["cfg" => 1])],
      [],
      [],
      [],
    );

    $this->nodeRuntime = $this->prophesize(NodeRuntimeServiceInterface::class);
    $this->pluginManager = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
    $this->entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);

    // Wire node type -> executor plugin -> processor (valid params by default).
    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn("search_plugin");
    // "q" is connectable and unpinned, so the model's "q" arg passes through.
    $nodeType->getParameters()->willReturn(["q" => ["connectable" => TRUE]]);
    $storage = $this->prophesize(EntityStorageInterface::class);
    $storage->load("search_type")->willReturn($nodeType->reveal());
    $this->entityTypeManager->getStorage("flowdrop_node_type")->willReturn($storage->reveal());

    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->validateParams(Argument::any())->willReturn(ValidationResult::success());
    // Exposure reads the param schema's `x-exposed-by-default`; an empty schema
    // leaves every param exposed-by-default.
    $processor->getParameterSchema()->willReturn([]);
    $this->pluginManager->createInstance("search_plugin")->willReturn($processor->reveal());

    $this->logger = $this->prophesize(LoggerInterface::class);

    return new ScopedToolInvoker(
      $allowedNodeIds,
      $graph,
      new NodeExecutionContext("w", "p", []),
      "exec_1",
      $this->pluginManager->reveal(),
      $this->entityTypeManager->reveal(),
      $this->nodeRuntime->reveal(),
      $this->logger->reveal(),
      $consumerNodeId,
      $pipeline,
      $dispatcher,
    );
  }

  /**
   * An unauthorized node is rejected outright (never executed).
   *
   * @covers ::invokeTool
   */
  public function testRejectsUnauthorizedNode(): void {
    $invoker = $this->invoker(["search_1"]);

    // The runtime must never be called for an unauthorized node.
    $this->nodeRuntime->executeNode(Argument::cetera())->shouldNotBeCalled();

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage("Unauthorized tool call");
    $invoker->invokeTool("evil_node", []);
  }

  /**
   * An authorized call runs the tool node and returns its output.
   *
   * @covers ::invokeTool
   */
  public function testInvokesAuthorizedNode(): void {
    $invoker = $this->invoker(["search_1"]);

    $output = new Output();
    $output->setSuccess();
    $output->setOutput("answer", 42);
    $this->nodeRuntime
      ->executeNode("exec_1", "search_1", "search_type", ["q" => "hi"], ["cfg" => 1], Argument::any())
      ->willReturn(new NodeExecutionResult("search_1", $output));

    $result = $invoker->invokeTool("search_1", ["q" => "hi"]);

    $this->assertTrue($result->isSuccess());
    $this->assertArrayHasKey("answer", $result->getData());
    $this->assertSame(42, $result->getData()["answer"]);
  }

  /**
   * A failed tool execution maps to an error result, not an exception.
   *
   * @covers ::invokeTool
   */
  public function testFailedExecutionReturnsErrorResult(): void {
    $invoker = $this->invoker(["search_1"]);

    $output = new Output();
    $output->setError("downstream boom");
    $this->nodeRuntime
      ->executeNode(Argument::cetera())
      ->willReturn(new NodeExecutionResult("search_1", $output));

    $result = $invoker->invokeTool("search_1", ["q" => "hi"]);

    $this->assertFalse($result->isSuccess());
    $this->assertSame("downstream boom", $result->getError());
  }

  /**
   * A derivative/prefixed tool node loads by entity ID, not executor plugin.
   *
   * The node type ID (executor plugin) is colon-separated for derivative or
   * module-prefixed plugins, but the flowdrop_node_type config entity ID is the
   * underscore form carried in metadata. The invoker must load and execute by
   * the entity ID — otherwise every non-stock tool node fails to resolve.
   *
   * @covers ::invokeTool
   */
  public function testResolvesNodeTypeByEntityIdNotExecutorPlugin(): void {
    $graph = new DependencyGraph(
      [
        "create_1" => new DependencyNode(
          "create_1",
          // Executor plugin ID (colon-separated derivative).
          "my_module:my_capability:create_content",
          "Create Content",
          ["cfg" => 1],
          [],
          [],
          // Metadata carries the real config entity ID (underscore form).
          ["node_type_id" => "my_module_my_capability_create_content"],
        ),
      ],
      [],
      [],
      [],
    );

    $nodeRuntime = $this->prophesize(NodeRuntimeServiceInterface::class);
    $pluginManager = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
    $entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);

    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn("my_module:my_capability:create_content");
    $nodeType->getParameters()->willReturn(["title" => ["connectable" => TRUE]]);
    $storage = $this->prophesize(EntityStorageInterface::class);
    // The colon-form executor plugin ID is NOT a valid entity ID.
    $storage->load("my_module:my_capability:create_content")->willReturn(NULL);
    $storage->load("my_module_my_capability_create_content")->willReturn($nodeType->reveal());
    $entityTypeManager->getStorage("flowdrop_node_type")->willReturn($storage->reveal());

    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->validateParams(Argument::any())->willReturn(ValidationResult::success());
    $processor->getParameterSchema()->willReturn([]);
    $pluginManager->createInstance("my_module:my_capability:create_content")->willReturn($processor->reveal());

    $output = new Output();
    $output->setSuccess();
    $output->setOutput("ok", TRUE);
    // executeNode must receive the entity ID, not the executor plugin ID.
    $nodeRuntime
      ->executeNode("exec_1", "create_1", "my_module_my_capability_create_content", ["title" => "x"], ["cfg" => 1], Argument::any())
      ->willReturn(new NodeExecutionResult("create_1", $output))
      ->shouldBeCalledOnce();

    $invoker = new ScopedToolInvoker(
      ["create_1"],
      $graph,
      new NodeExecutionContext("w", "p", []),
      "exec_1",
      $pluginManager->reveal(),
      $entityTypeManager->reveal(),
      $nodeRuntime->reveal(),
      $this->prophesize(LoggerInterface::class)->reveal(),
    );

    $result = $invoker->invokeTool("create_1", ["title" => "x"]);

    $this->assertTrue($result->isSuccess());
  }

  /**
   * A tool call is recorded as a normal pipeline job when a pipeline is live.
   *
   * It is created running, finalized completed, appended to the pipeline, and
   * announced via JobCompletedEvent — like any orchestrated node.
   *
   * @covers ::invokeTool
   */
  public function testRecordsToolCallAsPipelineJob(): void {
    // The real pipeline is a content entity; the reference append is
    // persisted immediately (wave-boundary reloads would drop it otherwise).
    $pipeline = $this->prophesize(FlowDropPipelineRuntimeInterface::class);
    $pipeline->willImplement(EntityInterface::class);
    $pipeline->getEntityTypeId()->willReturn("flowdrop_pipeline");
    $dispatcher = $this->prophesize(EventDispatcherInterface::class);
    $invoker = $this->invoker(["search_1"], $pipeline->reveal(), $dispatcher->reveal(), "agent_1");

    $pipelineStorage = $this->prophesize(EntityStorageInterface::class);
    $pipelineStorage->save(Argument::any())->willReturn(2)->shouldBeCalledOnce();
    $this->entityTypeManager->getStorage("flowdrop_pipeline")->willReturn($pipelineStorage->reveal());

    // The recorded job: started before execution, completed after, stamped
    // with the execution time.
    $job = $this->prophesize(FlowDropJobInterface::class);
    $revealedJob = $job->reveal();
    $job->markAsStarted()->willReturn($revealedJob)->shouldBeCalledOnce();
    $job->markAsCompleted(Argument::type("array"))->willReturn($revealedJob)->shouldBeCalledOnce();
    $job->getMetadata()->willReturn([]);
    $job->setMetadata(Argument::that(
      static fn(array $m): bool => array_key_exists("execution_time_us", $m),
    ))->willReturn($revealedJob)->shouldBeCalledOnce();

    // Job storage: the create() fields mark this as a tool invocation by the
    // consumer node; saved once running, once finalized.
    $jobStorage = $this->prophesize(EntityStorageInterface::class);
    $jobStorage->create(Argument::that(static function (array $values): bool {
      $metadata = json_decode((string) $values["metadata"], TRUE);
      return $values["label"] === "Web Search"
        && $values["node_type_id"] === "search_type"
        && json_decode((string) $values["input_data"], TRUE) === ["q" => "hi"]
        && $metadata["tool_invocation"] === TRUE
        && $metadata["invoked_by"] === "agent_1"
        && $metadata["node_id"] === "search_1";
    }))->willReturn($revealedJob)->shouldBeCalledOnce();
    $jobStorage->save($revealedJob)->willReturn(1)->shouldBeCalledTimes(2);
    $this->entityTypeManager->getStorage("flowdrop_job")->willReturn($jobStorage->reveal());

    // The job lands on the LIVE pipeline instance (persisted by the
    // orchestrator's end-of-run save — no competing save here).
    $pipeline->addJob($revealedJob)->willReturn($pipeline->reveal())->shouldBeCalledOnce();
    $pipeline->getExecutionContext()->willReturn([]);

    $dispatcher->dispatch(Argument::type(JobCompletedEvent::class), JobCompletedEvent::NAME)
      ->willReturnArgument(0)
      ->shouldBeCalledOnce();

    $output = new Output();
    $output->setSuccess();
    $output->setOutput("answer", 42);
    $this->nodeRuntime
      ->executeNode(Argument::cetera())
      ->willReturn(new NodeExecutionResult("search_1", $output));

    $result = $invoker->invokeTool("search_1", ["q" => "hi"]);

    $this->assertTrue($result->isSuccess());
  }

  /**
   * A failed execution still finalizes the recorded job — as failed.
   *
   * @covers ::invokeTool
   */
  public function testRecordsFailedToolCallAsFailedJob(): void {
    $pipeline = $this->prophesize(FlowDropPipelineRuntimeInterface::class);
    $dispatcher = $this->prophesize(EventDispatcherInterface::class);
    $invoker = $this->invoker(["search_1"], $pipeline->reveal(), $dispatcher->reveal(), "agent_1");

    $job = $this->prophesize(FlowDropJobInterface::class);
    $revealedJob = $job->reveal();
    $job->markAsStarted()->willReturn($revealedJob)->shouldBeCalledOnce();
    $job->markAsFailed("downstream boom")->willReturn($revealedJob)->shouldBeCalledOnce();
    $job->markAsCompleted(Argument::cetera())->shouldNotBeCalled();
    $job->getMetadata()->willReturn([]);
    $job->setMetadata(Argument::type("array"))->willReturn($revealedJob);

    $jobStorage = $this->prophesize(EntityStorageInterface::class);
    $jobStorage->create(Argument::type("array"))->willReturn($revealedJob);
    $jobStorage->save($revealedJob)->willReturn(1);
    $this->entityTypeManager->getStorage("flowdrop_job")->willReturn($jobStorage->reveal());

    $pipeline->addJob($revealedJob)->willReturn($pipeline->reveal());
    $pipeline->getExecutionContext()->willReturn([]);

    $dispatcher->dispatch(Argument::type(JobCompletedEvent::class), JobCompletedEvent::NAME)
      ->willReturnArgument(0)
      ->shouldBeCalledOnce();

    $output = new Output();
    $output->setError("downstream boom");
    $this->nodeRuntime
      ->executeNode(Argument::cetera())
      ->willReturn(new NodeExecutionResult("search_1", $output));

    $result = $invoker->invokeTool("search_1", ["q" => "hi"]);

    $this->assertFalse($result->isSuccess());
  }

  /**
   * Without a pipeline (legacy construction) nothing is recorded.
   *
   * There is no job storage access at all in that case.
   *
   * @covers ::invokeTool
   */
  public function testNoRecordingWithoutPipeline(): void {
    $invoker = $this->invoker(["search_1"]);

    $this->entityTypeManager->getStorage("flowdrop_job")->shouldNotBeCalled();

    $output = new Output();
    $output->setSuccess();
    $this->nodeRuntime
      ->executeNode(Argument::cetera())
      ->willReturn(new NodeExecutionResult("search_1", $output));

    $this->assertTrue($invoker->invokeTool("search_1", ["q" => "hi"])->isSuccess());
  }

  /**
   * An empty allow-list rejects every call — there is no permissive default.
   *
   * A consumer with no wired tools must end up with an invoker that denies
   * everything, not one that falls open.
   *
   * @covers ::invokeTool
   */
  public function testEmptyAllowListRejectsEverything(): void {
    $invoker = $this->invoker([]);

    $this->nodeRuntime->executeNode(Argument::cetera())->shouldNotBeCalled();

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage("Unauthorized tool call");
    // "search_1" exists in the graph — graph membership alone must not
    // grant anything.
    $invoker->invokeTool("search_1", []);
  }

  /**
   * A node wired to ANOTHER consumer is rejected for this one.
   *
   * This is the per-consumer scoping pin: the graph contains both tools,
   * but this invoker's allow-list only carries its own consumer's wiring.
   * A forged node ID pointing at a sibling consumer's tool must be denied
   * even though the node resolves fine in the graph.
   *
   * @covers ::invokeTool
   */
  public function testRejectsNodeWiredToAnotherConsumer(): void {
    $graph = new DependencyGraph(
      [
        "search_1" => new DependencyNode("search_1", "search_type", "Web Search", []),
        "delete_1" => new DependencyNode("delete_1", "delete_type", "Delete Content", []),
      ],
      [],
      [],
      [],
    );

    $nodeRuntime = $this->prophesize(NodeRuntimeServiceInterface::class);
    $nodeRuntime->executeNode(Argument::cetera())->shouldNotBeCalled();

    $invoker = new ScopedToolInvoker(
      // This consumer is only wired to the search tool.
      ["search_1"],
      $graph,
      new NodeExecutionContext("w", "p", []),
      "exec_1",
      $this->prophesize(FlowDropNodeProcessorPluginManager::class)->reveal(),
      $this->prophesize(EntityTypeManagerInterface::class)->reveal(),
      $nodeRuntime->reveal(),
      $this->prophesize(LoggerInterface::class)->reveal(),
    );

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage("Unauthorized tool call");
    $invoker->invokeTool("delete_1", []);
  }

  /**
   * An unauthorized call leaves an audit trail in the error log.
   *
   * @covers ::invokeTool
   */
  public function testUnauthorizedCallIsLogged(): void {
    $invoker = $this->invoker(["search_1"]);

    $this->logger->error(
      Argument::containingString("Unauthorized tool call"),
      Argument::that(static fn(array $ctx): bool =>
        ($ctx["@node"] ?? NULL) === "evil_node"
        && ($ctx["@exec"] ?? NULL) === "exec_1"),
    )->shouldBeCalledOnce();

    $this->expectException(\RuntimeException::class);
    $invoker->invokeTool("evil_node", []);
  }

  /**
   * The allow-list match is exact — no case folding, no trimming.
   *
   * @covers ::invokeTool
   */
  public function testAllowListMatchIsExact(): void {
    $invoker = $this->invoker(["search_1"]);

    $this->nodeRuntime->executeNode(Argument::cetera())->shouldNotBeCalled();

    foreach (["SEARCH_1", "search_1 ", " search_1", "search_10"] as $forged) {
      try {
        $invoker->invokeTool($forged, []);
        $this->fail(sprintf('Forged node ID "%s" was accepted.', $forged));
      }
      catch (\RuntimeException $e) {
        $this->assertStringContainsString("Unauthorized tool call", $e->getMessage());
      }
    }
  }

  /**
   * An allow-listed node missing from the graph throws — never executes.
   *
   * Defense in depth: if the allow-list and graph ever disagree (stale
   * projection), the call must fail closed rather than execute with a
   * NULL node.
   *
   * @covers ::invokeTool
   */
  public function testAllowedNodeMissingFromGraphThrows(): void {
    // "ghost_1" is allow-listed but does not exist in the helper's graph.
    $invoker = $this->invoker(["search_1", "ghost_1"]);

    $this->nodeRuntime->executeNode(Argument::cetera())->shouldNotBeCalled();

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage("was not found in the graph");
    $invoker->invokeTool("ghost_1", []);
  }

  /**
   * A param the author left not-exposed cannot be overridden by the model.
   *
   * The author configured `url` and marked it not-exposed via `config.ports`;
   * the model attempts to supply its own `url` plus a legitimate `query`. The
   * not-exposed `url` must be stripped from the runtime inputs (so the resolver
   * falls back to the author's config), while the exposed `query` passes
   * through. This is the enforcing half of exposure — schema projection alone
   * is advisory.
   *
   * @covers ::invokeTool
   */
  public function testPinnedParamCannotBeOverriddenByModel(): void {
    $graph = new DependencyGraph(
      [
        "fetch_1" => new DependencyNode(
          "fetch_1",
          "http_type",
          "Fetch",
          // The author configured the URL and hid it (not-exposed).
          [
            "url" => "https://safe.example.com",
            "ports" => ["inputs" => [["id" => "url", "exposed" => FALSE]]],
          ],
        ),
      ],
      [],
      [],
      [],
    );

    $nodeRuntime = $this->prophesize(NodeRuntimeServiceInterface::class);
    $pluginManager = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
    $entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);

    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn("http_plugin");
    // Both params are connectable; only `url` is hidden via config.ports.
    $nodeType->getParameters()->willReturn([
      "url" => ["connectable" => TRUE],
      "query" => ["connectable" => TRUE],
    ]);
    $storage = $this->prophesize(EntityStorageInterface::class);
    $storage->load("http_type")->willReturn($nodeType->reveal());
    $entityTypeManager->getStorage("flowdrop_node_type")->willReturn($storage->reveal());

    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->validateParams(Argument::any())->willReturn(ValidationResult::success());
    $processor->getParameterSchema()->willReturn([]);
    $pluginManager->createInstance("http_plugin")->willReturn($processor->reveal());

    $output = new Output();
    $output->setSuccess();
    // The model's malicious `url` is gone from runtime inputs; only `query`
    // remains. The author's `url` reaches execution as the node config.
    $nodeRuntime
      ->executeNode(
        "exec_1",
        "fetch_1",
        "http_type",
        ["query" => "cats"],
        [
          "url" => "https://safe.example.com",
          "ports" => ["inputs" => [["id" => "url", "exposed" => FALSE]]],
        ],
        Argument::any(),
      )
      ->willReturn(new NodeExecutionResult("fetch_1", $output))
      ->shouldBeCalledOnce();

    $invoker = new ScopedToolInvoker(
      ["fetch_1"],
      $graph,
      new NodeExecutionContext("w", "p", []),
      "exec_1",
      $pluginManager->reveal(),
      $entityTypeManager->reveal(),
      $nodeRuntime->reveal(),
      $this->prophesize(LoggerInterface::class)->reveal(),
    );

    $result = $invoker->invokeTool("fetch_1", [
      "url" => "https://evil.example.com",
      "query" => "cats",
    ]);

    $this->assertTrue($result->isSuccess());
  }

  /**
   * Invalid arguments are rejected before execution, as a recoverable error.
   *
   * @covers ::invokeTool
   */
  public function testInvalidArgsReturnErrorResult(): void {
    $invoker = $this->invoker(["search_1"]);

    // Override the processor to fail validation.
    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->validateParams(Argument::any())->willReturn(
      ValidationResult::error("q", "required"),
    );
    $processor->getParameterSchema()->willReturn([]);
    $this->pluginManager->createInstance("search_plugin")->willReturn($processor->reveal());

    $this->nodeRuntime->executeNode(Argument::cetera())->shouldNotBeCalled();

    $result = $invoker->invokeTool("search_1", []);

    $this->assertFalse($result->isSuccess());
    $this->assertNotNull($result->getError());
  }

}
