<?php

declare(strict_types=1);

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

use Prophecy\Argument;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Tests\UnitTestCase;
use Drupal\Tests\flowdrop_runtime\Unit\Service\Compiler\Fixtures\ToolPassthroughStub;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\FlowDropNodeProcessorInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop_node_type\FlowDropNodeTypeInterface;
use Drupal\flowdrop_runtime\DTO\Compiler\DependencyEdge;
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\Runtime\NodeRuntimeServiceInterface;
use Drupal\flowdrop_runtime\Service\Runtime\ToolProjector;
use Drupal\flowdrop_runtime\Service\Tool\ToolGraphFlattener;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;

/**
 * Tests that ToolProjector collects tools in the tool -> consumer direction.
 *
 * The editor draws a tool connection tool.output -> consumer.input, so the
 * tools wired to a consumer are its INCOMING tool_availability edges. This is
 * the direction the projector must read; the previous implementation read the
 * consumer's children (outgoing), which never matched editor output.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\Runtime\ToolProjector
 * @group flowdrop_runtime
 */
class ToolProjectorTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * Builds a graph: tool_1 -> agent_1 and tool_2 -> agent_1 (both tool edges).
   */
  private function graph(): DependencyGraph {
    $nodes = [
      "agent_1" => new DependencyNode("agent_1", "agent", "Agent"),
      "tool_1" => new DependencyNode("tool_1", "http_request", "Web Search"),
      "tool_2" => new DependencyNode("tool_2", "calculator", "Calculator"),
    ];
    $type = DependencyEdge::TYPE_TOOL_AVAILABILITY;
    $e1 = new DependencyEdge(
      "e1", "tool_1", "agent_1",
      "tool_1-output-tool", "agent_1-input-tool", $type, "tool", "tool",
    );
    $e2 = new DependencyEdge(
      "e2", "tool_2", "agent_1",
      "tool_2-output-tool", "agent_1-input-tool", $type, "tool", "tool",
    );

    return new DependencyGraph(
      nodes: $nodes,
      edges: ["e1" => $e1, "e2" => $e2],
      incomingEdgeIndex: ["agent_1" => [$e1, $e2]],
      outgoingEdgeIndex: ["tool_1" => [$e1], "tool_2" => [$e2]],
    );
  }

  /**
   * Builds a projector whose node-type lookups resolve to nothing.
   *
   * Description/schema reading is exercised elsewhere; here the storage returns
   * no entity so describe() yields empty defaults, keeping the test focused on
   * direction and binding identity.
   */
  private function projector(): ToolProjector {
    $storage = $this->prophesize(EntityStorageInterface::class);
    $storage->load(Argument::any())->willReturn(NULL);
    $etm = $this->prophesize(EntityTypeManagerInterface::class);
    $etm->getStorage("flowdrop_node_type")->willReturn($storage->reveal());

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

    // A real flattener over the same no-result mocks: nothing resolves to a
    // passthrough class, so every tool parent is a leaf (direct wiring).
    $flattener = new ToolGraphFlattener($pluginManager, $etm->reveal(), $logger);

    return new ToolProjector(
      $pluginManager,
      $etm->reveal(),
      $this->prophesize(NodeRuntimeServiceInterface::class)->reveal(),
      $logger,
      $flattener,
    );
  }

  /**
   * The consumer's incoming tool edges are projected into tool bindings.
   *
   * @covers ::project
   */
  public function testProjectsToolsFromIncomingEdges(): void {
    $context = new NodeExecutionContext("w", "p", []);
    $tools = $this->projector()
      ->project("agent_1", $this->graph(), $context, "exec1");

    $this->assertCount(2, $tools);
    $this->assertTrue($tools->has("web_search"));
    $this->assertTrue($tools->has("calculator"));
    // Model-facing name resolves to the internal routing key.
    $this->assertSame("tool_1", $tools->get("web_search")->getNodeId());
    $this->assertSame("tool_2", $tools->get("calculator")->getNodeId());
  }

  /**
   * A tool node has no tools of its own (it is the source, not the consumer).
   *
   * @covers ::project
   */
  public function testToolNodeProjectsNothing(): void {
    $context = new NodeExecutionContext("w", "p", []);
    $tools = $this->projector()
      ->project("tool_1", $this->graph(), $context, "exec1");

    $this->assertCount(0, $tools);
  }

  /**
   * Tools wired through a ToolBox are projected as the consumer's own tools.
   *
   * Graph: tool_1 -> box_1, tool_2 -> box_1, box_1 -> agent_1. The consumer
   * receives bindings for the leaf tools only — the box itself is never a
   * tool.
   *
   * @covers ::project
   */
  public function testFlattensThroughToolbox(): void {
    $nodes = [
      "agent_1" => new DependencyNode("agent_1", "agent", "Agent"),
      "box_1" => new DependencyNode("box_1", "toolbox", "My ToolBox"),
      "tool_1" => new DependencyNode("tool_1", "http_request", "Web Search"),
      "tool_2" => new DependencyNode("tool_2", "calculator", "Calculator"),
    ];
    $type = DependencyEdge::TYPE_TOOL_AVAILABILITY;
    $e1 = new DependencyEdge(
      "e1", "tool_1", "box_1",
      "tool_1-output-tool", "box_1-input-tool", $type, "tool", "tool",
    );
    $e2 = new DependencyEdge(
      "e2", "tool_2", "box_1",
      "tool_2-output-tool", "box_1-input-tool", $type, "tool", "tool",
    );
    $e3 = new DependencyEdge(
      "e3", "box_1", "agent_1",
      "box_1-output-tool", "agent_1-input-tool", $type, "tool", "tool",
    );
    $graph = new DependencyGraph(
      nodes: $nodes,
      edges: ["e1" => $e1, "e2" => $e2, "e3" => $e3],
      incomingEdgeIndex: ["box_1" => [$e1, $e2], "agent_1" => [$e3]],
      outgoingEdgeIndex: ["tool_1" => [$e1], "tool_2" => [$e2], "box_1" => [$e3]],
    );

    $context = new NodeExecutionContext("w", "p", []);
    $tools = $this->projectorWithToolboxType()
      ->project("agent_1", $graph, $context, "exec1");

    $this->assertCount(2, $tools);
    $this->assertTrue($tools->has("web_search"));
    $this->assertTrue($tools->has("calculator"));
    $this->assertFalse($tools->has("my_toolbox"));
    $this->assertSame("tool_1", $tools->get("web_search")->getNodeId());
    $this->assertSame("tool_2", $tools->get("calculator")->getNodeId());
  }

  /**
   * The projected tool schema drops params the author pinned in node config.
   *
   * Graph: fetch_1 -> agent_1. The fetch node's type declares two connectable
   * params (url, query); the node instance hides `url` via config.ports. The
   * model must only see `query` in the tool schema — `url` is the author's to
   * fix.
   *
   * @covers ::project
   */
  public function testProjectedSchemaOmitsPinnedParams(): void {
    $nodes = [
      "agent_1" => new DependencyNode("agent_1", "agent", "Agent"),
      "fetch_1" => new DependencyNode("fetch_1", "http_type", "Fetch", [
        "url" => "https://safe.example.com",
        "ports" => ["inputs" => [["id" => "url", "exposed" => FALSE]]],
      ]),
    ];
    $type = DependencyEdge::TYPE_TOOL_AVAILABILITY;
    $e1 = new DependencyEdge(
      "e1", "fetch_1", "agent_1",
      "fetch_1-output-tool", "agent_1-input-tool", $type, "tool", "tool",
    );
    $graph = new DependencyGraph(
      nodes: $nodes,
      edges: ["e1" => $e1],
      incomingEdgeIndex: ["agent_1" => [$e1]],
      outgoingEdgeIndex: ["fetch_1" => [$e1]],
    );

    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn("http_plugin");
    $nodeType->getParameters()->willReturn([
      "url" => ["connectable" => TRUE],
      "query" => ["connectable" => TRUE],
    ]);
    $storage = $this->prophesize(EntityStorageInterface::class);
    $storage->load(Argument::any())->willReturn(NULL);
    $storage->load("http_type")->willReturn($nodeType->reveal());
    $etm = $this->prophesize(EntityTypeManagerInterface::class);
    $etm->getStorage("flowdrop_node_type")->willReturn($storage->reveal());

    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->getDescription()->willReturn("Fetches a URL");
    $processor->getParameterSchema()->willReturn([
      "type" => "object",
      "properties" => [
        "url" => ["type" => "string"],
        "query" => ["type" => "string"],
      ],
      "required" => ["url"],
    ]);
    $pm = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
    $pm->createInstance("http_plugin")->willReturn($processor->reveal());
    // The flattener probes the plugin class for passthrough-ness; a plain class
    // is a leaf tool (not a ToolBox), so fetch_1 is projected directly.
    $pm->getDefinition("http_plugin", FALSE)->willReturn(["class" => \stdClass::class]);

    $logger = $this->prophesize(LoggerInterface::class)->reveal();
    $flattener = new ToolGraphFlattener($pm->reveal(), $etm->reveal(), $logger);
    $projector = new ToolProjector(
      $pm->reveal(),
      $etm->reveal(),
      $this->prophesize(NodeRuntimeServiceInterface::class)->reveal(),
      $logger,
      $flattener,
    );

    $tools = $projector->project("agent_1", $graph, new NodeExecutionContext("w", "p", []), "exec1");
    $schema = $tools->get("fetch")->getInputSchema();

    $this->assertSame(["query"], array_keys($schema["properties"]));
    // The pinned `url` is also dropped from `required`.
    $this->assertSame([], $schema["required"]);
  }

  /**
   * The projected tool schema is normalized to standard JSON Schema.
   *
   * A tool node's processor declares the relaxed internal dialect (per-property
   * `required` boolean, non-standard `mixed` type). The model-facing schema
   * must come out as standard JSON Schema: required lifted into the object
   * array, `mixed` dropped — so a provider accepts it without stripping.
   *
   * @covers ::project
   */
  public function testProjectedSchemaIsNormalizedForModel(): void {
    $nodes = [
      "agent_1" => new DependencyNode("agent_1", "agent", "Agent"),
      "shape_1" => new DependencyNode("shape_1", "shaper_type", "Shape"),
    ];
    $type = DependencyEdge::TYPE_TOOL_AVAILABILITY;
    $e1 = new DependencyEdge(
      "e1", "shape_1", "agent_1",
      "shape_1-output-tool", "agent_1-input-tool", $type, "tool", "tool",
    );
    $graph = new DependencyGraph(
      nodes: $nodes,
      edges: ["e1" => $e1],
      incomingEdgeIndex: ["agent_1" => [$e1]],
      outgoingEdgeIndex: ["shape_1" => [$e1]],
    );

    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn("shaper_plugin");
    $nodeType->getParameters()->willReturn([
      "value" => ["connectable" => TRUE],
      "shape" => ["connectable" => TRUE],
    ]);
    $storage = $this->prophesize(EntityStorageInterface::class);
    $storage->load(Argument::any())->willReturn(NULL);
    $storage->load("shaper_type")->willReturn($nodeType->reveal());
    $etm = $this->prophesize(EntityTypeManagerInterface::class);
    $etm->getStorage("flowdrop_node_type")->willReturn($storage->reveal());

    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->getDescription()->willReturn("Shapes a value");
    $processor->getParameterSchema()->willReturn([
      "type" => "object",
      "properties" => [
        "value" => ["type" => "mixed", "required" => TRUE],
        "shape" => ["type" => "string"],
      ],
    ]);
    $pm = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
    $pm->createInstance("shaper_plugin")->willReturn($processor->reveal());
    $pm->getDefinition("shaper_plugin", FALSE)->willReturn(["class" => \stdClass::class]);

    $logger = $this->prophesize(LoggerInterface::class)->reveal();
    $flattener = new ToolGraphFlattener($pm->reveal(), $etm->reveal(), $logger);
    $projector = new ToolProjector(
      $pm->reveal(),
      $etm->reveal(),
      $this->prophesize(NodeRuntimeServiceInterface::class)->reveal(),
      $logger,
      $flattener,
    );

    $tools = $projector->project("agent_1", $graph, new NodeExecutionContext("w", "p", []), "exec1");
    $schema = $tools->get("shape")->getInputSchema();

    // `mixed` is dropped; the per-property required is lifted + removed.
    $this->assertArrayNotHasKey("type", $schema["properties"]["value"]);
    $this->assertArrayNotHasKey("required", $schema["properties"]["value"]);
    $this->assertSame(["value"], $schema["required"]);
  }

  /**
   * A leaf tool whose processor can't be instantiated degrades, not fatals.
   *
   * Graph: fetch_1 -> agent_1. The node type resolves, but the plugin manager
   * hands back a null processor (a misbehaving/mocked manager — in production
   * createInstance() throws instead). describe() must normalize that into the
   * not-found fallback: the tool is still bound, with an empty description and
   * empty schema, and the failure is logged — never an uncaught Error from
   * null->getParameterSchema().
   *
   * @covers ::project
   */
  public function testNullProcessorDegradesToEmptyBinding(): void {
    $nodes = [
      "agent_1" => new DependencyNode("agent_1", "agent", "Agent"),
      "fetch_1" => new DependencyNode("fetch_1", "http_type", "Fetch"),
    ];
    $type = DependencyEdge::TYPE_TOOL_AVAILABILITY;
    $e1 = new DependencyEdge(
      "e1", "fetch_1", "agent_1",
      "fetch_1-output-tool", "agent_1-input-tool", $type, "tool", "tool",
    );
    $graph = new DependencyGraph(
      nodes: $nodes,
      edges: ["e1" => $e1],
      incomingEdgeIndex: ["agent_1" => [$e1]],
      outgoingEdgeIndex: ["fetch_1" => [$e1]],
    );

    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn("http_plugin");
    $nodeType->getParameters()->willReturn(["url" => ["connectable" => TRUE]]);
    $storage = $this->prophesize(EntityStorageInterface::class);
    $storage->load(Argument::any())->willReturn(NULL);
    $storage->load("http_type")->willReturn($nodeType->reveal());
    $etm = $this->prophesize(EntityTypeManagerInterface::class);
    $etm->getStorage("flowdrop_node_type")->willReturn($storage->reveal());

    $pm = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
    // The manager hands back null rather than throwing (in production it would
    // throw PluginNotFoundException; describe() normalizes the null into it).
    $pm->createInstance("http_plugin")->willReturn(NULL);
    // A plain class means fetch_1 is a leaf tool, so it gets described.
    $pm->getDefinition("http_plugin", FALSE)->willReturn(["class" => \stdClass::class]);

    // The undescribable tool is logged, not silently swallowed.
    $logger = $this->prophesize(LoggerInterface::class);
    $logger->warning(Argument::cetera())->shouldBeCalled();
    $flattener = new ToolGraphFlattener($pm->reveal(), $etm->reveal(), $logger->reveal());
    $projector = new ToolProjector(
      $pm->reveal(),
      $etm->reveal(),
      $this->prophesize(NodeRuntimeServiceInterface::class)->reveal(),
      $logger->reveal(),
      $flattener,
    );

    $tools = $projector->project("agent_1", $graph, new NodeExecutionContext("w", "p", []), "exec1");

    // The tool is still bound (by label), but degraded to empty schema.
    $this->assertCount(1, $tools);
    $this->assertTrue($tools->has("fetch"));
    $this->assertSame([], $tools->get("fetch")->getInputSchema());
  }

  /**
   * Builds a projector whose "toolbox" node type resolves to a passthrough.
   *
   * All other node-type lookups resolve to nothing (leaf tools with empty
   * description/schema), mirroring projector().
   */
  private function projectorWithToolboxType(): ToolProjector {
    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn("toolbox_plugin");
    $storage = $this->prophesize(EntityStorageInterface::class);
    $storage->load(Argument::any())->willReturn(NULL);
    $storage->load("toolbox")->willReturn($nodeType->reveal());
    $etm = $this->prophesize(EntityTypeManagerInterface::class);
    $etm->getStorage("flowdrop_node_type")->willReturn($storage->reveal());

    $pm = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
    $pm->getDefinition("toolbox_plugin", FALSE)
      ->willReturn(["class" => ToolPassthroughStub::class]);
    // The box's plugin is never instantiated; leaf tools resolve to no entity,
    // so describe() never reaches createInstance either.
    $pm->createInstance(Argument::cetera())->shouldNotBeCalled();

    $logger = $this->prophesize(LoggerInterface::class)->reveal();
    $flattener = new ToolGraphFlattener($pm->reveal(), $etm->reveal(), $logger);

    return new ToolProjector(
      $pm->reveal(),
      $etm->reveal(),
      $this->prophesize(NodeRuntimeServiceInterface::class)->reveal(),
      $logger,
      $flattener,
    );
  }

}
