<?php

declare(strict_types=1);

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

use Drupal\flowdrop_runtime\Exception\CompilationException;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\flowdrop_runtime\Service\Compiler\WorkflowCompiler;
use Drupal\flowdrop_workflow\DTO\WorkflowDTO;
use Drupal\flowdrop_workflow\DTO\WorkflowEdgeDTO;
use Drupal\flowdrop_workflow\DTO\WorkflowNodeDTO;
use PHPUnit\Framework\TestCase;

/**
 * Tests for WorkflowCompiler special edge handling.
 *
 * Tests that special edges (loopback, tool_availability, agent_result)
 * are properly excluded from DAG cycle detection.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\Compiler\WorkflowCompiler
 * @group flowdrop_runtime
 */
class WorkflowCompilerSpecialEdgeTest extends TestCase {

  /**
   * The mock logger factory.
   *
   * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface|\PHPUnit\Framework\MockObject\MockObject
   */
  protected $loggerFactory;

  /**
   * The mock logger.
   *
   * @var \Drupal\Core\Logger\LoggerChannelInterface|\PHPUnit\Framework\MockObject\MockObject
   */
  protected $logger;

  /**
   * The compiler under test.
   *
   * @var \Drupal\flowdrop_runtime\Service\Compiler\WorkflowCompiler
   */
  protected WorkflowCompiler $compiler;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $this->logger = $this->createMock(LoggerChannelInterface::class);
    $this->loggerFactory = $this->createMock(LoggerChannelFactoryInterface::class);
    $this->loggerFactory->method("get")->willReturn($this->logger);

    $this->compiler = new WorkflowCompiler($this->loggerFactory);
  }

  /**
   * Test that loopback edges don't cause cycle detection failure.
   *
   * Creates a workflow with an iterator pattern:
   * A -> Iterator -> B -> A (loopback)
   *
   * Without special edge handling, this would fail cycle detection.
   *
   * @covers ::compile
   */
  public function testLoopbackEdgeExcludedFromCycleDetection(): void {
    $workflow = $this->createWorkflowWithLoopback();

    // Should not throw CompilationException.
    $compiled = $this->compiler->compile($workflow);

    $this->assertNotNull($compiled);
    $this->assertSame("test_workflow", $compiled->getWorkflowId());

    // Verify execution graph was created.
    $executionGraph = $compiled->getExecutionGraph();
    $this->assertNotNull($executionGraph);

    // Execution order should include all nodes.
    $executionOrder = $executionGraph->getExecutionOrder();
    $this->assertContains("node_a", $executionOrder);
    $this->assertContains("iterator_1", $executionOrder);
    $this->assertContains("node_b", $executionOrder);
  }

  /**
   * Test that regular cycles still cause failure.
   *
   * Creates a workflow with a regular cycle:
   * A -> B -> A (normal data edge, not loopback)
   *
   * @covers ::compile
   */
  public function testRegularCycleStillDetected(): void {
    $workflow = $this->createWorkflowWithRegularCycle();

    $this->expectException(CompilationException::class);
    $this->expectExceptionMessage("Circular dependency");

    $this->compiler->compile($workflow);
  }

  /**
   * Test that loopback edges identified by target handle are excluded.
   *
   * Tests edge detection via handle naming pattern (-input-loop_back).
   *
   * @covers ::compile
   */
  public function testLoopbackEdgeDetectedByHandle(): void {
    $workflow = $this->createWorkflowWithLoopbackHandle();

    // Should not throw - loopback detected by handle pattern.
    $compiled = $this->compiler->compile($workflow);

    $this->assertNotNull($compiled);
  }

  /**
   * Test tool_availability edges are excluded.
   *
   * @covers ::compile
   */
  public function testToolAvailabilityEdgeExcluded(): void {
    $workflow = $this->createWorkflowWithToolEdge();

    // Should not throw.
    $compiled = $this->compiler->compile($workflow);

    $this->assertNotNull($compiled);

    // Tool nodes should be excluded from execution graph.
    $executionGraph = $compiled->getExecutionGraph();
    $this->assertTrue($executionGraph->isExcluded("tool_1"));
  }

  /**
   * Two tools on one node whose labels slug to the same name fail compilation.
   *
   * @covers ::compile
   */
  public function testCollidingToolNamesThrow(): void {
    $workflow = $this->createWorkflowMock([
      "id" => "collide_workflow",
      "nodes" => [
        "agent_1" => $this->createNode("agent_1", "agent"),
        // Distinct labels that both slug to "web_search".
        "tool_a" => $this->createNode("tool_a", "http_request", "Web Search"),
        "tool_b" => $this->createNode("tool_b", "http_request", "web-search"),
      ],
      "edges" => [
        $this->createEdge("e1", "tool_a", "agent_1", "", "", "tool_availability"),
        $this->createEdge("e2", "tool_b", "agent_1", "", "", "tool_availability"),
      ],
    ]);

    $this->expectException(CompilationException::class);
    $this->expectExceptionMessage('resolve to "web_search"');
    $this->compiler->compile($workflow);
  }

  /**
   * Distinct tool names on one node compile cleanly.
   *
   * @covers ::compile
   */
  public function testDistinctToolNamesCompile(): void {
    $workflow = $this->createWorkflowMock([
      "id" => "distinct_workflow",
      "nodes" => [
        "agent_1" => $this->createNode("agent_1", "agent"),
        "tool_a" => $this->createNode("tool_a", "http_request", "Web Search"),
        "tool_b" => $this->createNode("tool_b", "calculator", "Calculator"),
      ],
      "edges" => [
        $this->createEdge("e1", "tool_a", "agent_1", "", "", "tool_availability"),
        $this->createEdge("e2", "tool_b", "agent_1", "", "", "tool_availability"),
      ],
    ]);

    $this->assertNotNull($this->compiler->compile($workflow));
  }

  /**
   * The same tool name under two different consumers does not collide.
   *
   * @covers ::compile
   */
  public function testSameToolNameAcrossConsumersIsAllowed(): void {
    $workflow = $this->createWorkflowMock([
      "id" => "two_consumers_workflow",
      "nodes" => [
        "agent_1" => $this->createNode("agent_1", "agent"),
        "agent_2" => $this->createNode("agent_2", "agent"),
        "tool_a" => $this->createNode("tool_a", "http_request", "Search"),
        "tool_b" => $this->createNode("tool_b", "http_request", "Search"),
      ],
      "edges" => [
        $this->createEdge("e1", "tool_a", "agent_1", "", "", "tool_availability"),
        $this->createEdge("e2", "tool_b", "agent_2", "", "", "tool_availability"),
      ],
    ]);

    $this->assertNotNull($this->compiler->compile($workflow));
  }

  /**
   * Test agent_result edges are excluded.
   *
   * @covers ::compile
   */
  public function testAgentResultEdgeExcluded(): void {
    $workflow = $this->createWorkflowWithAgentResultEdge();

    // Should not throw.
    $compiled = $this->compiler->compile($workflow);

    $this->assertNotNull($compiled);
  }

  /**
   * Test mixed workflow with special and regular edges.
   *
   * @covers ::compile
   */
  public function testMixedWorkflowCompiles(): void {
    $workflow = $this->createMixedWorkflow();

    $compiled = $this->compiler->compile($workflow);

    $this->assertNotNull($compiled);

    // All non-excluded nodes should be in execution order.
    $executionOrder = $compiled->getExecutionGraph()->getExecutionOrder();
    $this->assertContains("start", $executionOrder);
    $this->assertContains("iterator_1", $executionOrder);
    $this->assertContains("process", $executionOrder);
    $this->assertContains("end", $executionOrder);
  }

  /**
   * Create workflow with loopback edge via metadata.
   *
   * @return \Drupal\flowdrop_workflow\DTO\WorkflowDTO
   *   The workflow DTO.
   */
  protected function createWorkflowWithLoopback(): WorkflowDTO {
    return $this->createWorkflowMock([
      "id" => "test_workflow",
      "nodes" => [
        "node_a" => $this->createNode("node_a", "text_input"),
        "iterator_1" => $this->createNode("iterator_1", "iterator"),
        "node_b" => $this->createNode("node_b", "processor"),
      ],
      "edges" => [
        $this->createEdge("e1", "node_a", "iterator_1", "", "", "data"),
        $this->createEdge("e2", "iterator_1", "node_b", "iterator_1-output-item", "", "data"),
        // Loopback edge - creates cycle but should be excluded.
        $this->createEdge("e3", "node_b", "iterator_1", "", "iterator_1-input-loop_back", "loopback"),
      ],
    ]);
  }

  /**
   * Create workflow with regular cycle (should fail).
   *
   * @return \Drupal\flowdrop_workflow\DTO\WorkflowDTO
   *   The workflow DTO.
   */
  protected function createWorkflowWithRegularCycle(): WorkflowDTO {
    return $this->createWorkflowMock([
      "id" => "cycle_workflow",
      "nodes" => [
        "node_a" => $this->createNode("node_a", "processor"),
        "node_b" => $this->createNode("node_b", "processor"),
      ],
      "edges" => [
        $this->createEdge("e1", "node_a", "node_b", "", "", "data"),
        // Regular data edge creating cycle - should fail.
        $this->createEdge("e2", "node_b", "node_a", "", "", "data"),
      ],
    ]);
  }

  /**
   * Create workflow with loopback detected by handle pattern.
   *
   * @return \Drupal\flowdrop_workflow\DTO\WorkflowDTO
   *   The workflow DTO.
   */
  protected function createWorkflowWithLoopbackHandle(): WorkflowDTO {
    return $this->createWorkflowMock([
      "id" => "handle_workflow",
      "nodes" => [
        "iterator_1" => $this->createNode("iterator_1", "iterator"),
        "node_a" => $this->createNode("node_a", "processor"),
      ],
      "edges" => [
        // Regular data edge.
        $this->createEdge("e1", "iterator_1", "node_a", "iterator_1-output-item", "", "data"),
        // Loopback detected by handle pattern, not metadata.
        // Pass NULL for edgeType so handle-based detection is used.
        $this->createEdge("e2", "node_a", "iterator_1", "", "iterator_1-input-loop_back", NULL),
      ],
    ]);
  }

  /**
   * Create workflow with tool_availability edge.
   *
   * @return \Drupal\flowdrop_workflow\DTO\WorkflowDTO
   *   The workflow DTO.
   */
  protected function createWorkflowWithToolEdge(): WorkflowDTO {
    return $this->createWorkflowMock([
      "id" => "tool_workflow",
      "nodes" => [
        "agent_1" => $this->createNode("agent_1", "agent"),
        "tool_1" => $this->createNode("tool_1", "http_request"),
      ],
      "edges" => [
        // Tool edge is drawn tool -> consumer; tool_1 only supplies a tool, so
        // it is excluded from the execution DAG.
        $this->createEdge("e1", "tool_1", "agent_1", "", "", "tool_availability"),
      ],
    ]);
  }

  /**
   * Create workflow with agent_result edge.
   *
   * @return \Drupal\flowdrop_workflow\DTO\WorkflowDTO
   *   The workflow DTO.
   */
  protected function createWorkflowWithAgentResultEdge(): WorkflowDTO {
    return $this->createWorkflowMock([
      "id" => "agent_workflow",
      "nodes" => [
        "start" => $this->createNode("start", "text_input"),
        "agent_1" => $this->createNode("agent_1", "agent"),
        "tool_1" => $this->createNode("tool_1", "calculator"),
      ],
      "edges" => [
        $this->createEdge("e1", "start", "agent_1", "", "", "data"),
        $this->createEdge("e2", "tool_1", "agent_1", "", "", "tool_availability"),
        // Agent result edge - creates cycle but excluded.
        $this->createEdge("e3", "tool_1", "agent_1", "", "", "agent_result"),
      ],
    ]);
  }

  /**
   * Create mixed workflow with various edge types.
   *
   * @return \Drupal\flowdrop_workflow\DTO\WorkflowDTO
   *   The workflow DTO.
   */
  protected function createMixedWorkflow(): WorkflowDTO {
    return $this->createWorkflowMock([
      "id" => "mixed_workflow",
      "nodes" => [
        "start" => $this->createNode("start", "text_input"),
        "iterator_1" => $this->createNode("iterator_1", "iterator"),
        "process" => $this->createNode("process", "processor"),
        "end" => $this->createNode("end", "text_output"),
      ],
      "edges" => [
        // Normal flow.
        $this->createEdge("e1", "start", "iterator_1", "", "", "data"),
        $this->createEdge("e2", "iterator_1", "process", "iterator_1-output-item", "", "data"),
        // Loopback (special).
        $this->createEdge("e3", "process", "iterator_1", "", "iterator_1-input-loop_back", "loopback"),
        // Continue to end.
        $this->createEdge("e4", "iterator_1", "end", "iterator_1-output-done", "", "data"),
      ],
    ]);
  }

  /**
   * Create a mock WorkflowDTO.
   *
   * @param array<string, mixed> $data
   *   Workflow data.
   *
   * @return \Drupal\flowdrop_workflow\DTO\WorkflowDTO|\PHPUnit\Framework\MockObject\MockObject
   *   The mock workflow.
   */
  protected function createWorkflowMock(array $data) {
    $workflow = $this->createMock(WorkflowDTO::class);

    $workflow->method("getId")->willReturn($data["id"]);
    $workflow->method("getNodes")->willReturn($data["nodes"]);
    $workflow->method("getNodeCount")->willReturn(count($data["nodes"]));
    $workflow->method("getEdgeCount")->willReturn(count($data["edges"]));

    $workflow->method("getNode")->willReturnCallback(
      fn($id) => $data["nodes"][$id] ?? NULL
    );

    $workflow->method("getEdges")->willReturn($data["edges"]);

    // Set up edge retrieval.
    $workflow->method("getOutgoingEdges")->willReturnCallback(
      function ($nodeId) use ($data) {
        return array_filter($data["edges"], fn($e) => $e->getSource() === $nodeId);
      }
    );

    $workflow->method("getIncomingEdges")->willReturnCallback(
      function ($nodeId) use ($data) {
        return array_filter($data["edges"], fn($e) => $e->getTarget() === $nodeId);
      }
    );

    return $workflow;
  }

  /**
   * Create a real node DTO for the compiler under test.
   *
   * @param string $id
   *   Node ID.
   * @param string $typeId
   *   Node type ID.
   * @param string|null $label
   *   Node label (defaults to "Node {id}").
   *
   * @return \Drupal\flowdrop_workflow\DTO\WorkflowNodeDTO
   *   The node DTO.
   */
  protected function createNode(string $id, string $typeId, ?string $label = NULL): WorkflowNodeDTO {
    return WorkflowNodeDTO::fromArray([
      "id" => $id,
      "type" => $typeId,
      "data" => [
        "label" => $label ?? "Node {$id}",
      ],
    ]);
  }

  /**
   * Create a real edge DTO for the compiler under test.
   *
   * @param string $id
   *   Edge ID.
   * @param string $source
   *   Source node ID.
   * @param string $target
   *   Target node ID.
   * @param string $sourceHandle
   *   Source handle.
   * @param string $targetHandle
   *   Target handle.
   * @param string|null $edgeType
   *   Edge type. Pass NULL to let handle-based detection work.
   *
   * @return \Drupal\flowdrop_workflow\DTO\WorkflowEdgeDTO
   *   The edge DTO.
   */
  protected function createEdge(
    string $id,
    string $source,
    string $target,
    string $sourceHandle = "",
    string $targetHandle = "",
    ?string $edgeType = "data",
  ): WorkflowEdgeDTO {
    return WorkflowEdgeDTO::fromArray([
      "id" => $id,
      "source" => $source,
      "target" => $target,
      "sourceHandle" => $sourceHandle,
      "targetHandle" => $targetHandle,
      // Only include edgeType in data when explicitly provided, so handle-based
      // detection is exercised when edgeType is NULL. The DTO derives trigger
      // flag and port/branch names from the handles itself.
      "data" => $edgeType !== NULL ? ["edgeType" => $edgeType] : [],
    ]);
  }

}
