<?php

declare(strict_types=1);

namespace Drupal\flowdrop_runtime\Service\Compiler;

use Psr\Log\LoggerInterface;
use Drupal\flowdrop\DTO\Tool\ToolName;
use Drupal\flowdrop\Utility\NodeAnchor;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\NonExecutableNodeProcessorInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop_runtime\DTO\Compiler\CompiledWorkflow;
use Drupal\flowdrop_runtime\DTO\Compiler\DependencyEdge;
use Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph;
use Drupal\flowdrop_runtime\DTO\Compiler\ExcludedNode;
use Drupal\flowdrop_runtime\DTO\Compiler\ExecutionGraph;
use Drupal\flowdrop_runtime\DTO\Compiler\ExecutionNode;
use Drupal\flowdrop_runtime\DTO\Compiler\NodeMapping;
use Drupal\flowdrop_workflow\DTO\WorkflowDTO;
use Drupal\flowdrop_runtime\Exception\CompilationException;
use Drupal\flowdrop_runtime\Exception\ToolWiringException;
use Drupal\flowdrop_runtime\Service\Tool\ToolGraphFlattenerInterface;
use Drupal\flowdrop_node_type\Service\NodeMetadataResolver;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;

/**
 * Compiles workflow entities into executable DAGs.
 *
 * Produces two graphs:
 * - DependencyGraph: Complete picture of all node connections
 * - ExecutionGraph: Only nodes that should be executed by the orchestrator.
 *
 * @internal This class is not part of the public API and may change without notice.
 */
class WorkflowCompiler implements WorkflowCompilerInterface {

  /**
   * Logger channel for this service.
   *
   * @var \Psr\Log\LoggerInterface
   */
  private readonly LoggerInterface $logger;

  /**
   * Constructs the WorkflowCompiler.
   *
   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $loggerFactory
   *   The logger channel factory.
   * @param \Drupal\flowdrop_runtime\Service\Compiler\ToolConsumerValidatorInterface|null $toolConsumerValidator
   *   Optional plugin-aware validator that checks tool_availability consumers
   *   implement ToolsAwareInterface. Omitted in dependency-light unit tests.
   * @param \Drupal\flowdrop_runtime\Service\Tool\ToolGraphFlattenerInterface|null $toolGraphFlattener
   *   Optional plugin-aware flattener resolving passthrough (ToolBox) wiring.
   *   When present, tool-name uniqueness is validated on the flattened leaf
   *   set and passthrough cycles become compile errors. Omitted in
   *   dependency-light unit tests, where direct edges are checked instead.
   * @param \Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager|null $nodeProcessorManager
   *   Optional processor plugin manager used to detect non-executable node
   *   processors (e.g. Note). When omitted in dependency-light unit tests,
   *   non-executable exclusion is skipped and topology rules still apply.
   * @param \Drupal\flowdrop_node_type\Service\NodeMetadataResolver|null $nodeMetadataResolver
   *   Optional resolver that re-injects fresh node metadata from the live node
   *   type before a raw workflow array is parsed into a WorkflowDTO, so
   *   execution is driven by the authoritative definition rather than the
   *   stored metadata cache. When omitted (dependency-light unit tests), the
   *   stored metadata is used as-is.
   */
  public function __construct(
    LoggerChannelFactoryInterface $loggerFactory,
    private readonly ?ToolConsumerValidatorInterface $toolConsumerValidator = NULL,
    private readonly ?ToolGraphFlattenerInterface $toolGraphFlattener = NULL,
    private readonly ?FlowDropNodeProcessorPluginManager $nodeProcessorManager = NULL,
    private readonly ?NodeMetadataResolver $nodeMetadataResolver = NULL,
  ) {
    $this->logger = $loggerFactory->get("flowdrop_runtime");
  }

  /**
   * Compiles a workflow entity into dependency and execution graphs.
   *
   * @param array<string, mixed>|\Drupal\flowdrop_workflow\DTO\WorkflowDTO $workflow
   *   The workflow definition array or WorkflowDTO.
   *
   * @return \Drupal\flowdrop_runtime\DTO\Compiler\CompiledWorkflow
   *   The compiled workflow with both graphs.
   *
   * @throws \Drupal\flowdrop_runtime\Exception\CompilationException
   *   When compilation fails due to validation errors.
   */
  public function compile(array|WorkflowDTO $workflow): CompiledWorkflow {
    // Convert array to WorkflowDTO if needed.
    if (is_array($workflow)) {
      // Re-inject fresh metadata from the live node type so the DTO resolves
      // executor plugins, defaults, and ports from the authoritative
      // definition rather than the (attacker-controllable, possibly stale)
      // stored metadata cache. Idempotent; skipped in resolver-less unit tests.
      if ($this->nodeMetadataResolver !== NULL) {
        $workflow = $this->nodeMetadataResolver->enrichWorkflow($workflow);
      }
      $workflowDTO = WorkflowDTO::fromArray($workflow);
    }
    else {
      $workflowDTO = $workflow;
    }

    $this->logger->info("Compiling workflow @id", [
      "@id" => $workflowDTO->getId(),
    ]);

    try {
      // Validate workflow structure.
      $this->validateWorkflowStructure($workflowDTO);

      // Build dependency graph (ALL edges).
      $dependencyGraph = DependencyGraph::fromWorkflow($workflowDTO);

      // Detect circular dependencies (excluding special edges).
      $this->detectCircularDependencies($dependencyGraph);

      // Validate tool wiring (e.g. unique tool names per consumer).
      $this->validateToolWiring($dependencyGraph);

      // Build execution graph (execution flow only).
      $executionGraph = $this->buildExecutionGraph($workflowDTO, $dependencyGraph);

      // Create node mappings.
      $nodeMappings = $this->createNodeMappings($workflowDTO);

      $compiledWorkflow = new CompiledWorkflow(
        workflowId: $workflowDTO->getId(),
        dependencyGraph: $dependencyGraph,
        executionGraph: $executionGraph,
        nodeMappings: $nodeMappings,
        metadata: [
          "totalNodeCount" => $dependencyGraph->getNodeCount(),
          "executableNodeCount" => $executionGraph->getNodeCount(),
          "excludedNodeCount" => $executionGraph->getExcludedCount(),
          "edgeCount" => $dependencyGraph->getEdgeCount(),
          "compilationTimestamp" => time(),
        ]
      );

      $this->logger->info("Successfully compiled workflow @id: @exec executable, @excluded excluded", [
        "@id" => $workflowDTO->getId(),
        "@exec" => $executionGraph->getNodeCount(),
        "@excluded" => $executionGraph->getExcludedCount(),
      ]);

      return $compiledWorkflow;

    }
    catch (\Exception $e) {
      $this->logger->error("Workflow compilation failed: @error", [
        "@error" => $e->getMessage(),
        "@workflow_id" => $workflowDTO->getId(),
      ]);

      throw new CompilationException(
        "Workflow compilation failed: " . $e->getMessage(),
        0,
        $e
      );
    }
  }

  /**
   * Validates workflow structure using DTO.
   *
   * @param \Drupal\flowdrop_workflow\DTO\WorkflowDTO $workflow
   *   The workflow DTO.
   *
   * @throws \Drupal\flowdrop_runtime\Exception\CompilationException
   *   When workflow structure is invalid.
   */
  private function validateWorkflowStructure(WorkflowDTO $workflow): void {
    if (empty($workflow->getId())) {
      throw new CompilationException("Workflow must have an ID");
    }

    if ($workflow->getNodeCount() === 0) {
      throw new CompilationException("Workflow must have at least one node");
    }

    // Validate each node has required properties.
    foreach ($workflow->getNodes() as $node) {
      if (empty($node->getId())) {
        throw new CompilationException("All nodes must have an ID");
      }

      if (empty($node->getTypeId())) {
        throw new CompilationException("Node {$node->getId()} must have a type");
      }
    }
  }

  /**
   * Detects circular dependencies using DFS.
   *
   * Only considers edges that affect execution order (data and trigger),
   * not special edges like tool_availability which are intentionally cyclic.
   *
   * @param \Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph $dependencyGraph
   *   The dependency graph.
   *
   * @throws \Drupal\flowdrop_runtime\Exception\CompilationException
   *   When circular dependencies are detected.
   */
  private function detectCircularDependencies(DependencyGraph $dependencyGraph): void {
    // Build a simplified graph with only execution-relevant edges.
    $executionEdges = [];
    foreach ($dependencyGraph->getAllNodes() as $nodeId => $node) {
      $executionEdges[$nodeId] = [];
    }

    foreach ($dependencyGraph->getAllEdges() as $edge) {
      // Only include edges that affect execution order.
      if (!$edge->isExcludedFromExecution()) {
        $executionEdges[$edge->getSource()][] = $edge->getTarget();
      }
    }

    $visited = [];
    $recursionStack = [];

    foreach (array_keys($executionEdges) as $nodeId) {
      if (!isset($visited[$nodeId])) {
        if ($this->hasCycle($nodeId, $executionEdges, $visited, $recursionStack)) {
          throw new CompilationException(
            "Circular dependency detected in workflow"
          );
        }
      }
    }
  }

  /**
   * Checks for cycles using DFS.
   *
   * @param string $nodeId
   *   The current node ID.
   * @param array<string, array<string>> $graph
   *   The adjacency list graph.
   * @param array<string, bool> $visited
   *   Visited nodes.
   * @param array<string, bool> $recursionStack
   *   Current recursion stack.
   *
   * @return bool
   *   TRUE if cycle is detected.
   */
  private function hasCycle(
    string $nodeId,
    array $graph,
    array &$visited,
    array &$recursionStack,
  ): bool {
    $visited[$nodeId] = TRUE;
    $recursionStack[$nodeId] = TRUE;

    foreach ($graph[$nodeId] as $dependentId) {
      if (!isset($visited[$dependentId])) {
        if ($this->hasCycle($dependentId, $graph, $visited, $recursionStack)) {
          return TRUE;
        }
      }
      elseif (isset($recursionStack[$dependentId]) && $recursionStack[$dependentId]) {
        return TRUE;
      }
    }

    $recursionStack[$nodeId] = FALSE;
    return FALSE;
  }

  /**
   * Validates the tool wiring of the graph.
   *
   * Tool names are derived from the tool node's label and must be unique within
   * a single consumer's tool set (the model addresses tools by name). Because
   * names are slugged, distinct labels can still collide (e.g. "Web Search" and
   * "web-search" both yield "web_search"); uniqueness is the site builder's
   * responsibility, caught here at compile time rather than silently later.
   *
   * @param \Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph $dependencyGraph
   *   The dependency graph.
   *
   * @throws \Drupal\flowdrop_runtime\Exception\CompilationException
   *   When two tools wired to the same node resolve to the same name.
   */
  private function validateToolWiring(DependencyGraph $dependencyGraph): void {
    foreach (array_keys($dependencyGraph->getAllNodes()) as $consumerId) {
      // Tool edges are drawn tool -> consumer, so a consumer's tools are its
      // incoming tool_availability edges; the tool node is the edge source.
      $toolEdges = $dependencyGraph->getIncomingEdges(
        $consumerId,
        DependencyEdge::TYPE_TOOL_AVAILABILITY,
      );
      if (empty($toolEdges)) {
        continue;
      }

      if ($this->toolGraphFlattener !== NULL) {
        // Flatten through passthrough nodes (ToolBoxes): names must be unique
        // on the leaf set each consumer actually receives. Flattening also
        // surfaces passthrough cycles and excessive nesting as compile errors.
        try {
          $toolIds = $this->toolGraphFlattener->flattenLeafTools($consumerId, $dependencyGraph);
        }
        catch (ToolWiringException $e) {
          throw new CompilationException($e->getMessage(), 0, $e);
        }
        // A passthrough's own tool set is not a model-facing manifest — name
        // uniqueness is enforced where the set lands, at the final consumer.
        // (Flattening above still ran, so cycles behind passthroughs are
        // caught even when every consumer in the chain is itself a box.)
        if ($this->toolGraphFlattener->isPassthrough($consumerId, $dependencyGraph)) {
          continue;
        }
      }
      else {
        // Dependency-light fallback (no plugin resolution available): check
        // the direct edge sources.
        $toolIds = array_map(
          static fn(DependencyEdge $edge): string => $edge->getSource(),
          $toolEdges,
        );
      }

      if (count($toolIds) < 2) {
        continue;
      }

      $seen = [];
      foreach ($toolIds as $toolId) {
        $toolNode = $dependencyGraph->getNode($toolId);
        $label = ($toolNode !== NULL && $toolNode->getLabel() !== "")
          ? $toolNode->getLabel()
          : $toolId;
        $name = ToolName::fromLabel($label);

        if (isset($seen[$name])) {
          throw new CompilationException(sprintf(
            'Tool name collision on node "%s": tools "%s" and "%s" both resolve to "%s". Tool names must be unique within a node\'s tool set.',
            $consumerId,
            $seen[$name],
            $toolId,
            $name,
          ));
        }
        $seen[$name] = $toolId;
      }
    }

    // Plugin-aware check (when a validator is wired): the consumer must be able
    // to consume tools, i.e. implement ToolsAwareInterface.
    if ($this->toolConsumerValidator !== NULL) {
      $errors = $this->toolConsumerValidator->validate($dependencyGraph);
      if (!empty($errors)) {
        throw new CompilationException(implode(" ", $errors));
      }
    }
  }

  /**
   * Builds the execution graph based on execution rules.
   *
   * Rules (in priority order):
   * 1. Nodes with ONLY
   *    tool_availability/loopback/agent_result
   *    inputs are excluded
   * 2. Edge-free nodes are standalone roots and are included
   * 3. Trigger edges determine execution order when present
   * 4. Data edges determine order if no triggers.
   *
   * @param \Drupal\flowdrop_workflow\DTO\WorkflowDTO $workflow
   *   The workflow DTO.
   * @param \Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph $dependencyGraph
   *   The dependency graph.
   *
   * @return \Drupal\flowdrop_runtime\DTO\Compiler\ExecutionGraph
   *   The execution graph.
   */
  private function buildExecutionGraph(
    WorkflowDTO $workflow,
    DependencyGraph $dependencyGraph,
  ): ExecutionGraph {
    $executableNodes = [];
    $excludedNodes = [];

    // First pass: identify which nodes should be executed vs excluded.
    foreach ($dependencyGraph->getAllNodes() as $nodeId => $depNode) {
      $exclusionReason = $this->shouldExcludeNode($nodeId, $dependencyGraph, $depNode->getTypeId());

      if ($exclusionReason !== NULL) {
        $excludedNodes[$nodeId] = $exclusionReason;
        $this->logger->debug("Excluding node @id: @reason", [
          "@id" => $nodeId,
          "@reason" => $exclusionReason->getReason(),
        ]);
      }
      else {
        $executableNodes[$nodeId] = $depNode;
      }
    }

    // Second pass: build execution dependencies for executable nodes.
    $executionNodeData = [];
    $inDegrees = [];

    foreach ($executableNodes as $nodeId => $depNode) {
      // Get trigger dependencies (highest priority).
      $triggerDeps = $this->getTriggerDependencies($nodeId, $dependencyGraph, $executableNodes);

      // Get data dependencies (for data resolution, not execution order).
      $dataDeps = $this->getDataDependencies($nodeId, $dependencyGraph);

      // Determine execution dependencies:
      // - If trigger edges exist, they control execution order
      // - Otherwise, data edges control execution order.
      $executionDeps = !empty($triggerDeps) ? $triggerDeps : $this->filterExecutableDeps($dataDeps, $executableNodes);

      $executionNodeData[$nodeId] = [
        "depNode" => $depNode,
        "triggerDeps" => $triggerDeps,
        "dataDeps" => $dataDeps,
        "executionDeps" => $executionDeps,
      ];

      $inDegrees[$nodeId] = count($executionDeps);
    }

    // Topological sort for execution order.
    $executionOrder = $this->topologicalSort($executionNodeData, $inDegrees);

    // Build dependents map (who depends on whom).
    $dependentsMap = $this->buildDependentsMap($executionNodeData);

    // Third pass: create ExecutionNode objects.
    $nodes = [];
    foreach ($executionNodeData as $nodeId => $data) {
      $depNode = $data["depNode"];

      // Determine if root (no execution dependencies).
      $isRoot = empty($data["executionDeps"]);

      // Determine if leaf (no dependents in execution graph).
      $isLeaf = empty($dependentsMap[$nodeId] ?? []);

      // Determine special type.
      $specialType = $depNode->getSpecialType();

      $nodes[$nodeId] = new ExecutionNode(
        id: $nodeId,
        typeId: $depNode->getTypeId(),
        config: $depNode->getConfig(),
        triggerDependencies: $data["triggerDeps"],
        dataDependencies: $data["dataDeps"],
        isRoot: $isRoot,
        isLeaf: $isLeaf,
        specialType: $specialType,
        metadata: [
          "label" => $depNode->getLabel(),
          "executionDependencies" => $data["executionDeps"],
        ]
      );
    }

    return new ExecutionGraph(
      nodes: $nodes,
      executionOrder: $executionOrder,
      excludedNodes: $excludedNodes,
      metadata: [
        "rootCount" => count(array_filter($nodes, fn($n) => $n->isRoot())),
        "leafCount" => count(array_filter($nodes, fn($n) => $n->isLeaf())),
        "agentCount" => count(array_filter($nodes, fn($n) => $n->isAgent())),
        "iteratorCount" => count(array_filter($nodes, fn($n) => $n->isIterator())),
      ]
    );
  }

  /**
   * Determines if a node should be excluded from execution.
   *
   * @param string $nodeId
   *   The node ID.
   * @param \Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph $dependencyGraph
   *   The dependency graph.
   * @param string $processorPluginId
   *   The node's processor plugin ID (executor plugin ID). Used to detect
   *   non-executable processors. Empty when unavailable.
   *
   * @return \Drupal\flowdrop_runtime\DTO\Compiler\ExcludedNode|null
   *   ExcludedNode if should be excluded, NULL otherwise.
   */
  private function shouldExcludeNode(
    string $nodeId,
    DependencyGraph $dependencyGraph,
    string $processorPluginId = "",
  ): ?ExcludedNode {
    // Non-executable processors (e.g. Note) declare via a marker interface that
    // they have no runtime behaviour. This is intent on the processor, not
    // inferred from topology, so it is checked first — a non-executable node is
    // excluded regardless of how it is wired.
    if ($processorPluginId !== "" && $this->isNonExecutableNodeProcessor($processorPluginId)) {
      return ExcludedNode::forNonExecutable($nodeId);
    }

    $incomingEdges = $dependencyGraph->getIncomingEdges($nodeId);
    $outgoingEdges = $dependencyGraph->getOutgoingEdges($nodeId);

    // A node with no edges at all is a standalone executable node (e.g. a
    // single-node workflow, or a node not yet wired up). It is NOT excluded:
    // it falls through to the root path below and executes on its own.
    // Non-executable processors were already handled above, so an edge-free
    // node reaching here is genuinely something we want to run.
    // Check if only connected as a tool supplier.
    // Tool edges are drawn tool -> consumer, so a tool node is the SOURCE of
    // its tool_availability edges: it is excluded if ALL its outgoing edges
    // are tool_availability (it feeds one or more consumers and nothing else).
    if (!empty($outgoingEdges)) {
      $hasNonToolOutput = FALSE;
      $toolConsumerNode = NULL;

      foreach ($outgoingEdges as $edge) {
        if ($edge->isToolAvailability()) {
          $toolConsumerNode = $edge->getTarget();
        }
        else {
          $hasNonToolOutput = TRUE;
          break;
        }
      }

      if (!$hasNonToolOutput && $toolConsumerNode !== NULL) {
        return ExcludedNode::forToolOnly($nodeId, $toolConsumerNode);
      }
    }

    // Root nodes (no incoming edges) should be included.
    if (empty($incomingEdges)) {
      return NULL;
    }

    // Check if only connected via loopback as INPUT.
    $hasNonLoopbackInput = FALSE;
    $loopbackParent = NULL;
    foreach ($incomingEdges as $edge) {
      if ($edge->isLoopback()) {
        $loopbackParent = $edge->getSource();
      }
      else {
        $hasNonLoopbackInput = TRUE;
        break;
      }
    }

    if (!$hasNonLoopbackInput && $loopbackParent !== NULL) {
      return ExcludedNode::forLoopbackOnly($nodeId, $loopbackParent);
    }

    return NULL;
  }

  /**
   * Checks if a processor is non-executable.
   *
   * @param string $processorPluginId
   *   The processor plugin ID (namespaced, e.g. "flowdrop_node_processor:note"
   *   — the executor plugin ID carried by DependencyNode::getTypeId()).
   *
   * @return bool
   *   TRUE if the processor implements NonExecutableNodeProcessorInterface.
   *   Always FALSE when no processor plugin manager was injected (dependency-
   *   light unit tests) — topology rules then apply unchanged.
   */
  private function isNonExecutableNodeProcessor(string $processorPluginId): bool {
    if ($this->nodeProcessorManager === NULL) {
      return FALSE;
    }

    try {
      $definition = $this->nodeProcessorManager->getDefinition($processorPluginId, FALSE);
      if ($definition === NULL) {
        return FALSE;
      }
      if (!isset($definition["class"])) {
        return FALSE;
      }

      $class = $definition["class"];
      return is_subclass_of($class, NonExecutableNodeProcessorInterface::class)
        || in_array(NonExecutableNodeProcessorInterface::class, class_implements($class), TRUE);
    }
    catch (\Exception $e) {
      // If we can't resolve the definition, assume it's executable.
      return FALSE;
    }
  }

  /**
   * Gets trigger dependencies for a node.
   *
   * @param string $nodeId
   *   The node ID.
   * @param \Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph $dependencyGraph
   *   The dependency graph.
   * @param array<string, mixed> $executableNodes
   *   Map of executable node IDs.
   *
   * @return array<string>
   *   Array of trigger dependency node IDs.
   */
  private function getTriggerDependencies(
    string $nodeId,
    DependencyGraph $dependencyGraph,
    array $executableNodes,
  ): array {
    $deps = [];
    $triggerEdges = $dependencyGraph->getIncomingEdges($nodeId, DependencyEdge::TYPE_TRIGGER);

    foreach ($triggerEdges as $edge) {
      $sourceId = $edge->getSource();
      // Only include if source is also executable.
      if (isset($executableNodes[$sourceId])) {
        $deps[] = $sourceId;
      }
    }

    return array_unique($deps);
  }

  /**
   * Gets data dependencies for a node.
   *
   * @param string $nodeId
   *   The node ID.
   * @param \Drupal\flowdrop_runtime\DTO\Compiler\DependencyGraph $dependencyGraph
   *   The dependency graph.
   *
   * @return array<string>
   *   Array of data dependency node IDs.
   */
  private function getDataDependencies(
    string $nodeId,
    DependencyGraph $dependencyGraph,
  ): array {
    $deps = [];
    $dataEdges = $dependencyGraph->getIncomingEdges($nodeId, DependencyEdge::TYPE_DATA);

    foreach ($dataEdges as $edge) {
      $deps[] = $edge->getSource();
    }

    return array_unique($deps);
  }

  /**
   * Filters dependencies to only include executable nodes.
   *
   * @param array<string> $deps
   *   Array of dependency node IDs.
   * @param array<string, mixed> $executableNodes
   *   Map of executable node IDs.
   *
   * @return array<string>
   *   Filtered array of executable dependency node IDs.
   */
  private function filterExecutableDeps(array $deps, array $executableNodes): array {
    return array_values(array_filter($deps, fn($id) => isset($executableNodes[$id])));
  }

  /**
   * Performs topological sort on execution nodes.
   *
   * @param array<string, array<string, mixed>> $nodeData
   *   Node data with execution dependencies.
   * @param array<string, int> $inDegrees
   *   In-degree counts for each node.
   *
   * @return array<string>
   *   Topologically sorted node IDs.
   */
  private function topologicalSort(array $nodeData, array $inDegrees): array {
    $queue = [];
    $result = [];

    // Find all nodes with no dependencies (in_degree = 0).
    foreach ($inDegrees as $nodeId => $degree) {
      if ($degree === 0) {
        $queue[] = $nodeId;
      }
    }

    // Build adjacency list for dependents.
    $dependents = [];
    foreach ($nodeData as $nodeId => $data) {
      foreach ($data["executionDeps"] as $depId) {
        $dependents[$depId][] = $nodeId;
      }
    }

    // Process queue.
    while (!empty($queue)) {
      $nodeId = array_shift($queue);
      $result[] = $nodeId;

      // Reduce in-degree for all dependents.
      foreach ($dependents[$nodeId] ?? [] as $dependentId) {
        $inDegrees[$dependentId]--;
        if ($inDegrees[$dependentId] === 0) {
          $queue[] = $dependentId;
        }
      }
    }

    return $result;
  }

  /**
   * Builds a map of which nodes depend on which other nodes.
   *
   * @param array<string, array<string, mixed>> $nodeData
   *   Node data with execution dependencies.
   *
   * @return array<string, array<string>>
   *   Map of node ID to array of dependent node IDs.
   */
  private function buildDependentsMap(array $nodeData): array {
    $dependents = [];
    foreach (array_keys($nodeData) as $nodeId) {
      $dependents[$nodeId] = [];
    }

    foreach ($nodeData as $nodeId => $data) {
      foreach ($data["executionDeps"] as $depId) {
        if (isset($dependents[$depId])) {
          $dependents[$depId][] = $nodeId;
        }
      }
    }

    return $dependents;
  }

  /**
   * Creates node mappings for processor plugins.
   *
   * @param \Drupal\flowdrop_workflow\DTO\WorkflowDTO $workflow
   *   The workflow DTO.
   *
   * @return array<string, NodeMapping>
   *   Array of NodeMapping objects keyed by node ID.
   */
  private function createNodeMappings(WorkflowDTO $workflow): array {
    $nodeMappings = [];

    foreach ($workflow->getNodes() as $nodeId => $node) {
      $metadata = [];
      if (method_exists($node, "getMetadata")) {
        $metadata = $node->getMetadata();
      }

      $inputs = [];
      if (method_exists($node, "getInputs")) {
        $inputs = $node->getInputs();
      }

      $outputs = [];
      if (method_exists($node, "getOutputs")) {
        $outputs = $node->getOutputs();
      }

      // The processor ID (executor plugin) and the node-type config-entity ID
      // are distinct identifiers: getTypeId() resolves to the executor plugin
      // (e.g. "flowdrop_node_processor:chat_input"), while the runtime loads
      // the node type from storage by its entity ID (the metadata.node_type_id
      // anchor, e.g. "chat_input"). Carry both so callers pass the right one.
      $nodeTypeId = NodeAnchor::readMetadata($metadata);

      $nodeMappings[$nodeId] = new NodeMapping(
        nodeId: $nodeId,
        processorId: $node->getTypeId(),
        config: $node->getConfig(),
        metadata: [
          "label" => $node->getLabel(),
          "description" => $metadata["description"] ?? "",
          "category" => $metadata["category"] ?? "",
          "inputs" => $inputs,
          "outputs" => $outputs,
        ],
        nodeTypeId: $nodeTypeId,
      );
    }

    return $nodeMappings;
  }

  /**
   * Validates a compiled workflow.
   *
   * @param \Drupal\flowdrop_runtime\DTO\Compiler\CompiledWorkflow $compiledWorkflow
   *   The compiled workflow to validate.
   *
   * @return bool
   *   TRUE if valid.
   */
  public function validateCompiledWorkflow(CompiledWorkflow $compiledWorkflow): bool {
    $executionGraph = $compiledWorkflow->getExecutionGraph();
    $nodeMappings = $compiledWorkflow->getNodeMappings();

    // Check that all nodes in execution order have mappings.
    foreach ($executionGraph->getExecutionOrder() as $nodeId) {
      if (!isset($nodeMappings[$nodeId])) {
        $this->logger->error("Execution graph references unmapped node: @node_id", [
          "@node_id" => $nodeId,
        ]);
        return FALSE;
      }
    }

    return TRUE;
  }

}
