<?php

declare(strict_types=1);

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

use Prophecy\Prophet;
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\Tests\flowdrop_runtime\Unit\Service\Compiler\Fixtures\ToolsAwareConsumerStub;
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\Exception\ToolWiringException;
use Drupal\flowdrop_runtime\Service\Tool\ToolGraphFlattener;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;

/**
 * Tests passthrough (ToolBox) flattening of tool wiring.
 *
 * Node types named "box*" resolve to a ToolPassthroughInterface processor;
 * "aware*" types resolve to a ToolsAware (but NOT passthrough) processor;
 * anything else is unresolvable and therefore a plain leaf tool.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\Tool\ToolGraphFlattener
 * @group flowdrop_runtime
 */
class ToolGraphFlattenerTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * Builds a graph from [source, target] tool-edge pairs.
   *
   * Node IDs double as node type IDs, so the type resolution in flattener()
   * can key off the ID prefix ("box*" → passthrough).
   *
   * @param array<int, array{0: string, 1: string}> $toolEdges
   *   Tool edges as [source, target] pairs.
   *
   * @return \Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph
   *   The graph.
   */
  private function graph(array $toolEdges): DependencyGraph {
    $nodes = [];
    $edges = [];
    $incoming = [];
    $outgoing = [];
    foreach ($toolEdges as $i => [$source, $target]) {
      foreach ([$source, $target] as $id) {
        $nodes[$id] ??= new DependencyNode($id, $id, ucfirst((string) $id));
      }
      $edge = new DependencyEdge(
        "e{$i}", $source, $target,
        "{$source}-output-tool", "{$target}-input-tool",
        DependencyEdge::TYPE_TOOL_AVAILABILITY, "tool", "tool",
      );
      $edges["e{$i}"] = $edge;
      $incoming[$target][] = $edge;
      $outgoing[$source][] = $edge;
    }

    return new DependencyGraph(
      nodes: $nodes,
      edges: $edges,
      incomingEdgeIndex: $incoming,
      outgoingEdgeIndex: $outgoing,
    );
  }

  /**
   * Builds a flattener resolving "box*" types to a passthrough processor.
   */
  private function flattener(): ToolGraphFlattener {
    $storage = $this->prophesize(EntityStorageInterface::class);
    $storage->load(Argument::type("string"))->will(function (array $args) {
      $typeId = $args[0];
      $isBox = str_starts_with($typeId, "box");
      $isAware = str_starts_with($typeId, "aware");
      if (!$isBox && !$isAware) {
        // Unresolvable type — treated as a plain leaf tool.
        return NULL;
      }
      $prophet = new Prophet();
      $nodeType = $prophet->prophesize(FlowDropNodeTypeInterface::class);
      $nodeType->getExecutorPlugin()->willReturn($isBox ? "box_plugin" : "aware_plugin");
      return $nodeType->reveal();
    });
    $etm = $this->prophesize(EntityTypeManagerInterface::class);
    $etm->getStorage("flowdrop_node_type")->willReturn($storage->reveal());

    $pm = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
    $pm->getDefinition("box_plugin", FALSE)->willReturn(["class" => ToolPassthroughStub::class]);
    $pm->getDefinition("aware_plugin", FALSE)->willReturn(["class" => ToolsAwareConsumerStub::class]);

    return new ToolGraphFlattener(
      $pm->reveal(),
      $etm->reveal(),
      $this->prophesize(LoggerInterface::class)->reveal(),
    );
  }

  /**
   * Direct tool wiring is returned as-is (no passthrough involved).
   *
   * @covers ::flattenLeafTools
   */
  public function testDirectToolsAreLeaves(): void {
    $graph = $this->graph([["tool_a", "agent"], ["tool_b", "agent"]]);
    $this->assertSame(
      ["tool_a", "tool_b"],
      $this->flattener()->flattenLeafTools("agent", $graph),
    );
  }

  /**
   * Tools wired into a box reach the box's consumer; the box itself does not.
   *
   * @covers ::flattenLeafTools
   */
  public function testFlattensThroughToolbox(): void {
    $graph = $this->graph([
      ["tool_a", "box_1"],
      ["tool_b", "box_1"],
      ["box_1", "agent"],
    ]);
    $this->assertSame(
      ["tool_a", "tool_b"],
      $this->flattener()->flattenLeafTools("agent", $graph),
    );
  }

  /**
   * Boxes nest: a box wired into a box contributes its flattened contents.
   *
   * @covers ::flattenLeafTools
   */
  public function testNestedToolboxes(): void {
    $graph = $this->graph([
      ["tool_a", "box_inner"],
      ["box_inner", "box_outer"],
      ["tool_b", "box_outer"],
      ["box_outer", "agent"],
    ]);
    $this->assertSame(
      ["tool_a", "tool_b"],
      $this->flattener()->flattenLeafTools("agent", $graph),
    );
  }

  /**
   * The same tool reached via two boxes is one tool, not a collision.
   *
   * @covers ::flattenLeafTools
   */
  public function testDiamondDedupesByNodeId(): void {
    $graph = $this->graph([
      ["tool_a", "box_1"],
      ["tool_a", "box_2"],
      ["box_1", "agent"],
      ["box_2", "agent"],
    ]);
    $this->assertSame(
      ["tool_a"],
      $this->flattener()->flattenLeafTools("agent", $graph),
    );
  }

  /**
   * A ToolsAware node wired as a tool stays one tool (agent-as-tool).
   *
   * Only the passthrough marker triggers flattening — a sub-agent's own tools
   * are its business, not the outer consumer's.
   *
   * @covers ::flattenLeafTools
   */
  public function testAgentAsToolIsNotFlattened(): void {
    $graph = $this->graph([
      ["tool_a", "aware_sub"],
      ["aware_sub", "agent"],
    ]);
    $this->assertSame(
      ["aware_sub"],
      $this->flattener()->flattenLeafTools("agent", $graph),
    );
  }

  /**
   * A passthrough cycle is a hard error naming the cycle path.
   *
   * @covers ::flattenLeafTools
   */
  public function testCycleThrows(): void {
    $graph = $this->graph([
      ["box_a", "box_b"],
      ["box_b", "box_a"],
      ["box_b", "agent"],
    ]);
    $this->expectException(ToolWiringException::class);
    $this->expectExceptionMessageMatches("/cycle/i");
    $this->flattener()->flattenLeafTools("agent", $graph);
  }

  /**
   * Passthrough nesting beyond the cap is a hard error.
   *
   * @covers ::flattenLeafTools
   */
  public function testDepthCapThrows(): void {
    $depth = ToolGraphFlattener::MAX_PASSTHROUGH_DEPTH + 1;
    $edges = [["tool_a", "box_0"]];
    for ($i = 1; $i < $depth; $i++) {
      $edges[] = ["box_" . ($i - 1), "box_{$i}"];
    }
    $edges[] = ["box_" . ($depth - 1), "agent"];

    $this->expectException(ToolWiringException::class);
    $this->expectExceptionMessageMatches("/depth/i");
    $this->flattener()->flattenLeafTools("agent", $this->graph($edges));
  }

  /**
   * A consumer with no tool wiring flattens to nothing.
   *
   * @covers ::flattenLeafTools
   */
  public function testNoToolEdgesYieldsEmptySet(): void {
    $graph = $this->graph([["tool_a", "agent"]]);
    $this->assertSame([], $this->flattener()->flattenLeafTools("tool_a", $graph));
  }

}
