<?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 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-loopback).
   *
   * @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"));
  }

  /**
   * 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->createNodeMock("node_a", "text_input"),
        "iterator_1" => $this->createNodeMock("iterator_1", "iterator"),
        "node_b" => $this->createNodeMock("node_b", "processor"),
      ],
      "edges" => [
        $this->createEdgeMock("e1", "node_a", "iterator_1", "", "", "data"),
        $this->createEdgeMock("e2", "iterator_1", "node_b", "iterator_1-output-item", "", "data"),
        // Loopback edge - creates cycle but should be excluded.
        $this->createEdgeMock("e3", "node_b", "iterator_1", "", "iterator_1-input-loopback", "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->createNodeMock("node_a", "processor"),
        "node_b" => $this->createNodeMock("node_b", "processor"),
      ],
      "edges" => [
        $this->createEdgeMock("e1", "node_a", "node_b", "", "", "data"),
        // Regular data edge creating cycle - should fail.
        $this->createEdgeMock("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->createNodeMock("iterator_1", "iterator"),
        "node_a" => $this->createNodeMock("node_a", "processor"),
      ],
      "edges" => [
        // Regular data edge.
        $this->createEdgeMock("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->createEdgeMock("e2", "node_a", "iterator_1", "", "iterator_1-input-loopback", 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->createNodeMock("agent_1", "agent"),
        "tool_1" => $this->createNodeMock("tool_1", "http_request"),
      ],
      "edges" => [
        // Tool availability edge - should be excluded from DAG.
        $this->createEdgeMock("e1", "agent_1", "tool_1", "", "", "tool_availability"),
        // Result edge back to agent.
        $this->createEdgeMock("e2", "tool_1", "agent_1", "", "", "agent_result"),
      ],
    ]);
  }

  /**
   * 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->createNodeMock("start", "text_input"),
        "agent_1" => $this->createNodeMock("agent_1", "agent"),
        "tool_1" => $this->createNodeMock("tool_1", "calculator"),
      ],
      "edges" => [
        $this->createEdgeMock("e1", "start", "agent_1", "", "", "data"),
        $this->createEdgeMock("e2", "agent_1", "tool_1", "", "", "tool_availability"),
        // Agent result edge - creates cycle but excluded.
        $this->createEdgeMock("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->createNodeMock("start", "text_input"),
        "iterator_1" => $this->createNodeMock("iterator_1", "iterator"),
        "process" => $this->createNodeMock("process", "processor"),
        "end" => $this->createNodeMock("end", "text_output"),
      ],
      "edges" => [
        // Normal flow.
        $this->createEdgeMock("e1", "start", "iterator_1", "", "", "data"),
        $this->createEdgeMock("e2", "iterator_1", "process", "iterator_1-output-item", "", "data"),
        // Loopback (special).
        $this->createEdgeMock("e3", "process", "iterator_1", "", "iterator_1-input-loopback", "loopback"),
        // Continue to end.
        $this->createEdgeMock("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 mock node.
   *
   * @param string $id
   *   Node ID.
   * @param string $typeId
   *   Node type ID.
   *
   * @return object|\PHPUnit\Framework\MockObject\MockObject
   *   The mock node.
   */
  protected function createNodeMock(string $id, string $typeId) {
    $node = $this->getMockBuilder(\stdClass::class)
      ->addMethods(["getId", "getTypeId", "getConfig", "getLabel", "getMetadata", "getInputs", "getOutputs"])
      ->getMock();

    $node->method("getId")->willReturn($id);
    $node->method("getTypeId")->willReturn($typeId);
    $node->method("getConfig")->willReturn([]);
    $node->method("getLabel")->willReturn("Node {$id}");
    $node->method("getMetadata")->willReturn([]);
    $node->method("getInputs")->willReturn([]);
    $node->method("getOutputs")->willReturn([]);

    return $node;
  }

  /**
   * Create a mock edge.
   *
   * @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 object|\PHPUnit\Framework\MockObject\MockObject
   *   The mock edge.
   */
  protected function createEdgeMock(
    string $id,
    string $source,
    string $target,
    string $sourceHandle = "",
    string $targetHandle = "",
    ?string $edgeType = "data",
  ) {
    $edge = $this->getMockBuilder(\stdClass::class)
      ->addMethods([
        "getId",
        "getSource",
        "getTarget",
        "getSourceHandle",
        "getTargetHandle",
        "getMetadata",
        "getData",
        "getSourceOutputName",
        "getTargetInputName",
        "isTrigger",
        "getBranchName",
      ])
      ->getMock();

    $edge->method("getId")->willReturn($id);
    $edge->method("getSource")->willReturn($source);
    $edge->method("getTarget")->willReturn($target);
    $edge->method("getSourceHandle")->willReturn($sourceHandle);
    $edge->method("getTargetHandle")->willReturn($targetHandle);

    // Only include edgeType in metadata/data if explicitly provided.
    // This allows handle-based detection to work when edgeType is NULL.
    if ($edgeType !== NULL) {
      $edge->method("getMetadata")->willReturn(["edgeType" => $edgeType]);
      $edge->method("getData")->willReturn(["edgeType" => $edgeType]);
    }
    else {
      $edge->method("getMetadata")->willReturn([]);
      $edge->method("getData")->willReturn([]);
    }
    $edge->method("isTrigger")->willReturn(FALSE);
    $edge->method("getBranchName")->willReturn("");

    // Extract port names from handles.
    $sourceOutputName = "";
    if (preg_match("/-output-(.+)$/", $sourceHandle, $matches)) {
      $sourceOutputName = $matches[1];
    }
    $targetInputName = "";
    if (preg_match("/-input-(.+)$/", $targetHandle, $matches)) {
      $targetInputName = $matches[1];
    }

    $edge->method("getSourceOutputName")->willReturn($sourceOutputName);
    $edge->method("getTargetInputName")->willReturn($targetInputName);

    return $edge;
  }

}
