<?php

declare(strict_types=1);

namespace Drupal\flowdrop\Utility;

use Drupal\flowdrop\Constants\ReservedName;
use Drupal\flowdrop\DTO\ExecutionEdge;
use Drupal\flowdrop\DTO\JsApi\Edge as TransportEdge;

/**
 * Classifies workflow edges by their reserved port handles.
 *
 * The single owner of the handle-suffix rules that decide whether an edge is
 * a trigger, loopback, or tool connection. Handles are formatted
 * "{nodeId}-{direction}-{portName}", so a reserved input port is the
 * "-input-{portName}" *suffix*. The match must be exact (suffix, not
 * substring): a substring match would misclassify any port whose name merely
 * begins with a reserved one — e.g. a data edge into "…-input-tool_calls"
 * must not be read as a tool edge.
 *
 * The edge-shape methods take a typed edge — a {@see TransportEdge} (the
 * editor's camelCase wire format) or an {@see ExecutionEdge} (the snake_case
 * job-metadata record) — never a raw array. The two shapes are distinct
 * domains; routing each through its own type is what lets this classifier read
 * a single `getSourceHandle()`/`getTargetHandle()` contract instead of
 * duck-typing key casing.
 *
 * Consumers: the workflow compiler's DependencyEdge (flowdrop_runtime) and
 * JobGenerationService (flowdrop_pipeline). Both previously carried their own
 * copies of these rules, which drifted (`str_contains` vs `str_ends_with`);
 * keep all classification here.
 *
 * @api
 */
final class EdgePortClassifier {

  private function __construct() {}

  /**
   * Checks whether a target handle addresses a given reserved input port.
   *
   * @param string $targetHandle
   *   The edge's target handle.
   * @param string $portName
   *   The reserved port name (e.g. ReservedName::PORT_TOOL).
   *
   * @return bool
   *   TRUE when the handle addresses that reserved input port.
   */
  public static function isReservedInputPort(string $targetHandle, string $portName): bool {
    $suffix = '-' . ReservedName::PORT_INPUT . '-' . $portName;
    return str_ends_with($targetHandle, $suffix);
  }

  /**
   * Checks whether a source handle emits from a given reserved output port.
   *
   * @param string $sourceHandle
   *   The edge's source handle.
   * @param string $portName
   *   The reserved port name (e.g. ReservedName::PORT_ERROR).
   *
   * @return bool
   *   TRUE when the handle emits from that reserved output port.
   */
  public static function isReservedOutputPort(string $sourceHandle, string $portName): bool {
    $suffix = '-' . ReservedName::PORT_OUTPUT . '-' . $portName;
    return str_ends_with($sourceHandle, $suffix);
  }

  /**
   * Whether an execution edge originates from its source node's error port.
   *
   * An error edge is drawn node.error_output → handler.input, identified by
   * the source handle "{nodeId}-output-error". Reads the typed job-metadata
   * record, so no key-casing fallback is needed.
   *
   * @param \Drupal\flowdrop\DTO\ExecutionEdge $edge
   *   The execution edge (job-metadata record).
   *
   * @return bool
   *   TRUE when the edge defines the source node's error path.
   */
  public static function isErrorEdge(ExecutionEdge $edge): bool {
    return self::isReservedOutputPort($edge->getSourceHandle(), ReservedName::PORT_ERROR);
  }

  /**
   * Whether the edge lands on the target's trigger input port.
   *
   * @param string $targetHandle
   *   The edge's target handle.
   *
   * @return bool
   *   TRUE for "{nodeId}-input-trigger".
   */
  public static function isTriggerTarget(string $targetHandle): bool {
    return self::isReservedInputPort($targetHandle, ReservedName::PORT_TRIGGER);
  }

  /**
   * Whether the edge lands on the target's loopback input port.
   *
   * @param string $targetHandle
   *   The edge's target handle.
   *
   * @return bool
   *   TRUE for "{nodeId}-input-loop_back".
   */
  public static function isLoopbackTarget(string $targetHandle): bool {
    return self::isReservedInputPort($targetHandle, ReservedName::PORT_LOOPBACK);
  }

  /**
   * Whether the edge lands on the target's tool input port.
   *
   * A tool connection is drawn tool.output → consumer.input, so the
   * consumer's tool input handle ("{nodeId}-input-tool") identifies it.
   *
   * @param string $targetHandle
   *   The edge's target handle.
   *
   * @return bool
   *   TRUE for "{nodeId}-input-tool".
   */
  public static function isToolTarget(string $targetHandle): bool {
    return self::isReservedInputPort($targetHandle, ReservedName::PORT_TOOL);
  }

  /**
   * Extracts the port name from a handle string.
   *
   * Handle format: {nodeId}-{direction}-{portName}.
   * Example: "text_input.1-output-text" → "text".
   *
   * @param string $handle
   *   The handle string.
   * @param string $direction
   *   The direction (ReservedName::PORT_OUTPUT or ::PORT_INPUT).
   *
   * @return string
   *   The port name, or empty string if parsing failed.
   */
  public static function portName(string $handle, string $direction): string {
    if ($handle === '') {
      return '';
    }
    $parts = preg_split("/-{$direction}-/", $handle, 2);
    if ($parts !== FALSE && count($parts) === 2) {
      return $parts[1];
    }
    return '';
  }

  /**
   * Whether a transport edge is a tool connection.
   *
   * Honors an explicit `data.edgeType` declaration first, then the target
   * handle — the same precedence the compiler's DependencyEdge applies.
   *
   * @param \Drupal\flowdrop\DTO\JsApi\Edge $edge
   *   The transport (editor) edge.
   *
   * @return bool
   *   TRUE when the edge supplies a tool to its target.
   */
  public static function isToolEdge(TransportEdge $edge): bool {
    $declared = $edge->getData()['edgeType'] ?? NULL;
    if (is_string($declared) && $declared !== '') {
      return $declared === 'tool_availability';
    }
    return self::isToolTarget($edge->getTargetHandle());
  }

  /**
   * Whether a node exists only to be invoked as a tool.
   *
   * Mirrors WorkflowCompiler::shouldExcludeNode(): a node is tool-only when
   * it has at least one outgoing edge and ALL of its outgoing edges are tool
   * connections. Such nodes execute inline via the tool invoker when their
   * consumer calls them — they must not be scheduled as ordinary jobs. A
   * node with mixed outputs (tool + normal) is NOT tool-only.
   *
   * @param string $nodeId
   *   The node id.
   * @param array<int, \Drupal\flowdrop\DTO\JsApi\Edge> $edges
   *   All workflow transport (editor) edges.
   *
   * @return bool
   *   TRUE when the node is tool-only.
   */
  public static function isToolOnlyNode(string $nodeId, array $edges): bool {
    $hasOutgoing = FALSE;
    foreach ($edges as $edge) {
      if ($edge->getSource() !== $nodeId) {
        continue;
      }
      $hasOutgoing = TRUE;
      if (!self::isToolEdge($edge)) {
        return FALSE;
      }
    }
    return $hasOutgoing;
  }

}
