<?php

declare(strict_types=1);

namespace Drupal\flowdrop_node_type\Service;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\flowdrop\Constants\ReservedName;
use Drupal\flowdrop\DTO\NodeMetadata\NodeMetadata;
use Drupal\flowdrop\DTO\NodeMetadata\NodeMetadataBuilder;
use Drupal\flowdrop\DTO\NodeMetadata\NodePort;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\ConfigEditProviderInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\NonExecutableNodeProcessorInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\StartNodeProcessorInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\TerminalNodeProcessorInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop\Utility\NodeAnchor;
use Drupal\flowdrop\Utility\PortExposure;
use Drupal\flowdrop_node_type\FlowDropNodeTypeInterface;

/**
 * Resolves the authoritative metadata for a node type from the live source.
 *
 * The node type entity + the node processor plugin's parameter schema are the
 * single source of truth for a node's ports, config schema, defaults, executor
 * plugin, and visual type. This service derives that metadata on demand so it
 * never has to be trusted from stored workflow JSON.
 *
 * Two consumers:
 * - The node catalog API (NodesController) builds the palette from it.
 * - The workflow read path enriches stored nodes with it, so the editor renders
 *   against current definitions and the persisted node can stay slim.
 *
 * @see docs/development/workflow-node-format.md
 *
 * @internal
 *   This class is not part of the public API and may change without notice.
 *   Not declared final only so it can be test-doubled by consumers.
 */
class NodeMetadataResolver {

  public function __construct(
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    protected readonly LoggerChannelFactoryInterface $loggerFactory,
    protected readonly FlowDropNodeProcessorPluginManager $pluginManager,
    protected readonly UiSchemaGenerator $uiSchemaGenerator,
  ) {}

  /**
   * Enrich a list of stored workflow nodes with fresh metadata.
   *
   * For each node, re-injects the constant React Flow `type` and a freshly
   * derived `data.metadata` block from the current node type. The node's user
   * config (`data.config`), id, label, and position are left untouched. Nodes
   * whose type cannot be resolved are returned unchanged.
   *
   * @param array<int, array<string, mixed>> $nodes
   *   The stored workflow nodes.
   *
   * @return array<int, array<string, mixed>>
   *   The enriched nodes.
   */
  public function enrichNodes(array $nodes): array {
    foreach ($nodes as $index => $node) {
      if (is_array($node)) {
        $nodes[$index] = $this->enrichNode($node);
      }
    }
    return $nodes;
  }

  /**
   * Enrich the nodes of a raw workflow array with fresh metadata.
   *
   * Mirrors the shape handling of WorkflowDTO::fromArray(): the node list is
   * read from `data.nodes` when a `data` wrapper is present, otherwise from the
   * top-level `nodes`. Enriching the same slot the DTO reads keeps execution
   * driven by the live node type rather than the stored metadata cache.
   *
   * Idempotent: safe to call on already-enriched or still-fat workflow arrays.
   *
   * @param array<string, mixed> $workflowData
   *   The raw workflow array (full API response or direct data).
   *
   * @return array<string, mixed>
   *   The workflow array with its nodes enriched.
   */
  public function enrichWorkflow(array $workflowData): array {
    if (isset($workflowData['data']) && is_array($workflowData['data'])) {
      if (isset($workflowData['data']['nodes']) && is_array($workflowData['data']['nodes'])) {
        $workflowData['data']['nodes'] = $this->enrichNodes($workflowData['data']['nodes']);
      }
    }
    elseif (isset($workflowData['nodes']) && is_array($workflowData['nodes'])) {
      $workflowData['nodes'] = $this->enrichNodes($workflowData['nodes']);
    }
    return $workflowData;
  }

  /**
   * Enrich a single stored workflow node with fresh metadata.
   *
   * @param array<string, mixed> $node
   *   The stored node.
   *
   * @return array<string, mixed>
   *   The enriched node, or the original if the node type cannot be resolved.
   */
  public function enrichNode(array $node): array {
    // The node-type entity id is the one anchor that is always persisted.
    // NodeAnchor reads `node_type_id` exclusively and asserts loudly if it
    // meets an un-migrated legacy node (`metadata.id` without `node_type_id`).
    $typeId = NodeAnchor::read($node);
    if ($typeId === '') {
      return $node;
    }

    $metadata = $this->buildForNodeTypeId($typeId);
    if ($metadata === NULL) {
      return $node;
    }

    // Re-inject the constant React Flow component type and fresh metadata.
    // The editor reads ports/configSchema/type from this block.
    $node['type'] = 'universalNode';
    $node['data'] = is_array($node['data'] ?? NULL) ? $node['data'] : [];
    $metadataArray = $metadata->toArray();

    // The uiSchema baseline ships every group collapsed. Now that this
    // instance's saved config is in scope, open any group whose fields the user
    // has set so a configured value is never hidden behind a collapsed group.
    if (is_array($metadataArray['uiSchema'] ?? NULL)) {
      $config = is_array($node['data']['config'] ?? NULL) ? $node['data']['config'] : [];
      $metadataArray['uiSchema'] = $this->uiSchemaGenerator->applyInstanceOpenState(
        $metadataArray['uiSchema'],
        $config,
        is_array($metadataArray['configSchema'] ?? NULL) ? $metadataArray['configSchema'] : [],
      );
    }

    $node['data']['metadata'] = $metadataArray;

    return $node;
  }

  /**
   * Build node metadata for a node type id.
   *
   * Convenience wrapper that loads the node type entity from its id (the anchor
   * persisted on a stored node) and builds its metadata.
   *
   * @param string $nodeTypeId
   *   The node type entity id.
   *
   * @return \Drupal\flowdrop\DTO\NodeMetadata\NodeMetadata|null
   *   The metadata, or NULL if the node type does not exist or has no plugin.
   */
  public function buildForNodeTypeId(string $nodeTypeId): ?NodeMetadata {
    if ($nodeTypeId === '') {
      return NULL;
    }
    $entity = $this->entityTypeManager
      ->getStorage('flowdrop_node_type')
      ->load($nodeTypeId);
    if (!$entity instanceof FlowDropNodeTypeInterface) {
      return NULL;
    }
    return $this->buildForNodeType($entity);
  }

  /**
   * Resolve the executor plugin id for a node type id.
   *
   * The authoritative executor plugin lives on the node type entity, not in the
   * stored node metadata cache. This is the light lookup for callers that only
   * need the plugin id (module-dependency calculation, export `requires`,
   * schema building) without building the full metadata DTO.
   *
   * @param string $nodeTypeId
   *   The node type entity id (the persisted node_type_id anchor).
   *
   * @return string|null
   *   The namespaced executor plugin id, or NULL if the node type does not
   *   exist or declares no executor plugin.
   */
  public function resolveExecutorPlugin(string $nodeTypeId): ?string {
    if ($nodeTypeId === '') {
      return NULL;
    }
    $entity = $this->entityTypeManager
      ->getStorage('flowdrop_node_type')
      ->load($nodeTypeId);
    if (!$entity instanceof FlowDropNodeTypeInterface) {
      return NULL;
    }
    $pluginId = $entity->getExecutorPlugin();
    return $pluginId === '' ? NULL : $pluginId;
  }

  /**
   * Build complete node metadata by merging entity and plugin data.
   *
   * Uses the Unified Parameter System to derive inputSchema and configSchema
   * from the plugin's parameterSchema combined with entity configuration.
   *
   * Note: The visual type is controlled by the config entity, not the plugin.
   * When multiple supported types are configured, a "type" parameter is added
   * to the configSchema to allow per-instance type selection.
   *
   * @param \Drupal\flowdrop_node_type\FlowDropNodeTypeInterface $flowdropNodeType
   *   The node type entity.
   * @param \Drupal\Core\Cache\CacheableMetadata|null $cacheMetadata
   *   Optional cache metadata to add dependencies to.
   *
   * @return \Drupal\flowdrop\DTO\NodeMetadata\NodeMetadata|null
   *   The complete node metadata DTO or NULL if the plugin is not found.
   */
  public function buildForNodeType(FlowDropNodeTypeInterface $flowdropNodeType, ?CacheableMetadata $cacheMetadata = NULL): ?NodeMetadata {
    try {
      $executorPlugin = $flowdropNodeType->getExecutorPlugin();

      $inputs = [];
      $outputs = [];
      $configSchema = [];
      $configDefaults = [];

      if ($executorPlugin && $this->pluginManager->hasDefinition($executorPlugin)) {
        $nodeProcessor = $this->pluginManager->createInstance($executorPlugin);

        // Get unified parameter schema from plugin.
        $parameterSchema = $nodeProcessor->getParameterSchema();
        $outputSchema = $nodeProcessor->getOutputSchema();

        // Get entity configuration.
        $entityParameters = $flowdropNodeType->getParameters();
        $entityOutputs = $flowdropNodeType->getOutputs();

        // Derive inputSchema and configSchema
        // from parameterSchema + entity config.
        $derivedSchemas = $this->deriveSchemas($parameterSchema, $entityParameters);
        $inputSchema = $derivedSchemas["inputSchema"];
        $configSchema = $derivedSchemas["configSchema"];
        $configDefaults = $derivedSchemas["configDefaults"];

        // Add the reserved trigger input to every node, except start nodes.
        // A start node is a workflow entry point — nothing upstream fires it,
        // so an incoming trigger port is meaningless there. Triggers are one
        // kind of start node (Trigger ⊂ Start via the interface hierarchy);
        // gating on the general marker keeps the check about the capability,
        // not the Trigger subtype.
        if (!($nodeProcessor instanceof StartNodeProcessorInterface)
          && empty($inputSchema["properties"][ReservedName::PORT_TRIGGER])) {
          $inputSchema["properties"][ReservedName::PORT_TRIGGER] = [
            "type" => "trigger",
            "title" => "Trigger",
            "required" => FALSE,
            "x-port-order" => 100,
          ];
        }

        // Transform schemas to port arrays.
        if (!empty($inputSchema["properties"])) {
          $inputs = $this->transformSchemaToPorts($inputSchema, "input");
        }

        // Filter outputs based on entity configuration.
        $filteredOutputSchema = $this->filterOutputSchema($outputSchema, $entityOutputs);

        // Add the reserved trigger output to every node, except terminal nodes.
        // This lets a node emit a trigger signal for flow control. A terminal
        // node ends the run when reached (it throws WorkflowStopException and
        // abandons everything downstream), so an outgoing trigger port is
        // meaningless there — the outbound dual of suppressing the trigger
        // input on start nodes above.
        if (!($nodeProcessor instanceof TerminalNodeProcessorInterface)
          && empty($filteredOutputSchema["properties"][ReservedName::PORT_TRIGGER])) {
          $filteredOutputSchema["properties"][ReservedName::PORT_TRIGGER] = [
            "type" => "trigger",
            "title" => "Trigger",
            "description" => "Emitted when this node completes execution",
            "x-port-order" => 100,
          ];
        }

        // Add tool output if the node type is exposed as a tool and the
        // processor doesn't declare one. Lets any node be wired as a tool
        // for tool-aware consumers, directly or through a ToolBox.
        if ($flowdropNodeType->isToolExposed()
          && empty($filteredOutputSchema["properties"][ReservedName::PORT_TOOL])) {
          $filteredOutputSchema["properties"][ReservedName::PORT_TOOL] = [
            "type" => "tool",
            "title" => "Tool",
            "description" => "Expose this node as a callable tool",
            "x-port-order" => 110,
          ];
        }

        // Add the reserved error output port to every executable node (Note and
        // other non-executable nodes never fail, so they get no error path).
        // It ships NOT exposed (`x-exposed-by-default: false`): hidden on the
        // canvas until the author reveals it, at which point wiring an edge
        // from it enables error routing for the node. Skip if the processor
        // already declares its own error port.
        if (!($nodeProcessor instanceof NonExecutableNodeProcessorInterface)
          && empty($filteredOutputSchema["properties"][ReservedName::PORT_ERROR])) {
          $filteredOutputSchema["properties"][ReservedName::PORT_ERROR] = [
            "type" => "object",
            "title" => "Error",
            "description" => "Emitted when this node fails; carries {message, code, node_id, retryable}. Wire it to route failures instead of failing the run.",
            "x-exposed-by-default" => FALSE,
            "x-port-order" => 120,
          ];
        }

        if (!empty($filteredOutputSchema["properties"])) {
          $outputs = $this->transformSchemaToPorts($filteredOutputSchema, "output");
        }

        // Add unified "input" port if configured (reserved name).
        // Only includes keys that are connectable (respects site admin config).
        if ($flowdropNodeType->isUnifiedInputExposed()) {
          $connectableKeys = $this->getConnectableKeys($parameterSchema, $entityParameters);
          $keysDescription = !empty($connectableKeys)
            ? "Available keys: " . implode(", ", $connectableKeys)
            : "No connectable parameters available";

          array_unshift($inputs, new NodePort(
            id: ReservedName::PORT_INPUT,
            name: "Input",
            type: "input",
            dataType: "json",
            required: FALSE,
            description: "All inputs as JSON object. {$keysDescription}",
          ));
        }

        // Add unified "output" port if configured (reserved name).
        // Only includes keys that are exposed (respects site admin config).
        if ($flowdropNodeType->isUnifiedOutputExposed()) {
          $exposedKeys = $this->getExposedOutputKeys($outputSchema, $entityOutputs);
          $keysDescription = !empty($exposedKeys)
            ? "Available keys: " . implode(", ", $exposedKeys)
            : "No exposed outputs available";

          array_unshift($outputs, new NodePort(
            id: ReservedName::PORT_OUTPUT,
            name: "Output",
            type: "output",
            dataType: "json",
            required: FALSE,
            description: "All outputs as JSON object. {$keysDescription}",
          ));
        }
      }

      // Get visual type configuration from config entity.
      $visualType = $flowdropNodeType->getVisualType();
      $supportedTypes = $flowdropNodeType->getSupportedVisualTypes();

      // Ensure supportedTypes has at least the default type.
      if (empty($supportedTypes)) {
        $supportedTypes = [$visualType];
      }

      // If multiple types are supported, add a "type" parameter to configSchema
      // so users can change the visual type per node instance.
      if (count($supportedTypes) > 1) {
        $configSchema["properties"][ReservedName::NODE_TYPE] = [
          "type" => "string",
          "title" => "Visual Type",
          "description" => "Select the visual appearance for this node instance.",
          "enum" => $supportedTypes,
          "default" => $visualType,
          "x-group" => UiSchemaGenerator::GROUP_GENERAL,
        ];
        $configDefaults["type"] = $visualType;
      }

      // Inject the reserved config properties every node carries: instance
      // title/description (General group) for all nodes, plus — for executable
      // nodes — the retry budget (Execution) and the port order/exposure widget
      // (Ports). Factored into injectReservedConfigProperties() so dynamic
      // schema endpoints that serve only their own domain fields can present an
      // identical grouped form without re-implementing this machinery.
      $executable = isset($nodeProcessor)
        && !($nodeProcessor instanceof NonExecutableNodeProcessorInterface);
      $configSchema = $this->injectReservedConfigProperties($configSchema, $executable);

      // Check if the plugin provides configEdit metadata for dynamic
      // configuration. This allows plugins to specify how their configuration
      // UI should be loaded (dynamic schema from API, external edit links,
      // or both). Uses ConfigEditProviderInterface for type-safe interface
      // checking.
      $configEdit = NULL;
      if (isset($nodeProcessor) && $nodeProcessor instanceof ConfigEditProviderInterface) {
        $configEdit = $nodeProcessor->getConfigEdit();
      }

      // Derive the config form layout (groups injected reserved props into
      // collapsible fieldsets). NULL when nothing is grouped, keeping the form
      // on its flat rendering path. The baseline ships every group collapsed;
      // enrichNode() opens a group per-instance when its fields are set.
      $uiSchema = $this->uiSchemaGenerator->generate($configSchema);

      // Build NodeMetadata using the fluent builder.
      return NodeMetadataBuilder::create()
        ->setId((string) $flowdropNodeType->id())
        ->setName((string) $flowdropNodeType->label())
        ->setType($visualType)
        ->setSupportedTypes($supportedTypes)
        ->setDescription($flowdropNodeType->getDescription())
        ->setCategory($flowdropNodeType->getCategory())
        ->setIcon($flowdropNodeType->getIcon())
        ->setPluginVersion($flowdropNodeType->getPluginVersion())
        ->setEnabled($flowdropNodeType->isEnabled())
        ->setTags($flowdropNodeType->getTags())
        ->setExecutorPlugin($executorPlugin)
        ->setInputs($inputs)
        ->setOutputs($outputs)
        ->setConfig($configDefaults)
        ->setConfigSchema($configSchema)
        ->setConfigEdit($configEdit)
        ->setUiSchema($uiSchema)
        ->build();
    }
    catch (\Exception $e) {
      $this->loggerFactory->get("flowdrop")->error("Failed to build node data for @id: @error", [
        "@id" => $flowdropNodeType->id(),
        "@error" => $e->getMessage(),
      ]);
      return NULL;
    }
  }

  /**
   * Inject the reserved config properties every node carries into a schema.
   *
   * The single home for the reserved-name config props the config form groups
   * into fieldsets:
   * - instance title + description (General) — added to every node; the title
   *   and description double as the tool name/description a tool-exposed node
   *   presents to a model at runtime.
   * - max retries (Execution) — executable nodes only; read at job generation
   *   into the job's max_retries and consumed when the processor throws a
   *   RetryableExceptionInterface. Meaningless for non-executable nodes.
   * - ports (Ports) — executable nodes only; the `ports` format signals the
   *   editor to render the unified port order/exposure widget (the widget reads
   *   the node's ports + defaults from metadata, so no port list is embedded).
   *
   * Each is skipped when the schema already declares that property, so a plugin
   * can define its own. Called by buildForNodeType() for the static node-type
   * schema, and by dynamic-schema endpoints (e.g. the trigger config editor)
   * that serve a narrow domain schema but want the identical grouped form.
   *
   * @param array<string, mixed> $configSchema
   *   The config schema to hydrate (a JSON Schema object).
   * @param bool $executable
   *   Whether the node executes at runtime. Non-executable nodes get only the
   *   General props; the Execution and Ports props are skipped for them.
   *
   * @return array<string, mixed>
   *   The config schema with the reserved properties injected.
   */
  public function injectReservedConfigProperties(array $configSchema, bool $executable): array {
    if (!isset($configSchema["properties"]) || !is_array($configSchema["properties"])) {
      $configSchema["properties"] = [];
    }

    // Instance display overrides (General) — position at the top of the form
    // via x-display-order. Added to every node.
    if (!isset($configSchema["properties"][ReservedName::INSTANCE_TITLE])) {
      $configSchema["properties"][ReservedName::INSTANCE_TITLE] = [
        "type" => "string",
        "title" => "Title",
        "description" => "Override the default node title for this instance.",
        "x-display-order" => -2,
        "x-group" => UiSchemaGenerator::GROUP_GENERAL,
      ];
    }
    if (!isset($configSchema["properties"][ReservedName::INSTANCE_DESCRIPTION])) {
      $configSchema["properties"][ReservedName::INSTANCE_DESCRIPTION] = [
        "type" => "string",
        "title" => "Description",
        "description" => "Override the default description for this instance.",
        "format" => "multiline",
        "x-display-order" => -1,
        "x-group" => UiSchemaGenerator::GROUP_GENERAL,
      ];
    }

    if (!$executable) {
      return $configSchema;
    }

    // Per-node retry budget (Execution) — executable nodes only.
    if (!isset($configSchema["properties"][ReservedName::MAX_RETRIES])) {
      $configSchema["properties"][ReservedName::MAX_RETRIES] = [
        "type" => "integer",
        "title" => "Max retries",
        "description" => "How many times to retry this node on a transient, retryable failure (0 = no retry). Retries are immediate, with no delay, and only apply to failures the node marks retryable.",
        "minimum" => 0,
        "default" => 0,
        "x-display-order" => 50,
        "x-group" => UiSchemaGenerator::GROUP_EXECUTION,
      ];
    }

    // Per-instance port order + exposure (Ports) — executable nodes only.
    // Order is cosmetic; exposure is semantic in v2, so the engine reads
    // exposure from `data.config`.
    if (!isset($configSchema["properties"][ReservedName::PORTS])) {
      $configSchema["properties"][ReservedName::PORTS] = [
        "type" => "object",
        "title" => "Ports",
        "description" => "Order and exposure of this node's ports. A not-exposed port is hidden on the canvas, cannot be wired, and is not runtime-overridable.",
        "format" => "ports",
        "x-display-order" => 60,
        "x-group" => UiSchemaGenerator::GROUP_PORTS,
      ];
    }

    return $configSchema;
  }

  /**
   * Get the parameter schema from a plugin.
   *
   * @param string $pluginId
   *   The plugin ID.
   *
   * @return array<string, mixed>
   *   The plugin's parameter schema.
   */
  public function getPluginParameterSchema(string $pluginId): array {
    try {
      if ($this->pluginManager->hasDefinition($pluginId)) {
        $plugin = $this->pluginManager->createInstance($pluginId);
        return $plugin->getParameterSchema();
      }
    }
    catch (\Exception $e) {
      $this->loggerFactory->get("flowdrop")->error("Failed to get parameter schema for @id: @error", [
        "@id" => $pluginId,
        "@error" => $e->getMessage(),
      ]);
    }

    return [];
  }

  /**
   * Derive inputSchema and configSchema from unified parameterSchema.
   *
   * - Parameters with connectable=TRUE go into inputSchema (input ports)
   * - Parameters with configurable=TRUE go into configSchema (config form)
   * - Default values are extracted for the config object.
   *
   * @param array<string, mixed> $parameterSchema
   *   The plugin's parameter schema from getParameterSchema().
   * @param array<string, array<string, mixed>> $entityParameters
   *   The entity's parameter configuration (controls port/form behavior).
   *
   * @return array<string, mixed>
   *   Array with 'inputSchema', 'configSchema', and 'configDefaults'.
   */
  protected function deriveSchemas(array $parameterSchema, array $entityParameters): array {
    $inputProperties = [];
    $configProperties = [];
    $configDefaults = [];
    $inputRequired = [];
    $configRequired = [];

    $properties = $parameterSchema["properties"] ?? [];

    foreach ($properties as $paramName => $paramSchema) {
      // Reserved config-schema-only params (dynamicInputs, dynamicOutputs,
      // branches) are, when surfaced:
      // 1. Routed to configSchema (never connectable as input ports)
      // 2. Rendered by the UI's dynamic-port / branch manager.
      // Individual port VALUES are resolved separately by ParameterResolver.
      //
      // But they are surfaced ONLY when the node type entity opts in by
      // declaring the parameter as configurable. This keeps the editor in step
      // with ParameterResolver, which resolves these definitions from stored
      // values only when the entity marks the param configurable. A node type
      // that does not declare the param shows no manager and gets no runtime
      // resolution — a plugin's getParameterSchema() offering the capability is
      // not enough; the sitebuilder opts in per node type.
      if (in_array($paramName, ReservedName::configSchemaOnlyParams(), TRUE)) {
        $entityConfig = $entityParameters[$paramName] ?? [];
        if (($entityConfig["configurable"] ?? FALSE) !== TRUE) {
          continue;
        }
        $cleanSchema = $this->cleanPropertySchema($paramSchema);
        $configProperties[$paramName] = $cleanSchema;
        $entityDefault = $entityConfig["default"] ?? NULL;
        $configDefaults[$paramName] = $entityDefault ?? $paramSchema["default"] ?? [];
        continue;
      }

      // Skip hidden parameters.
      // These are automatically injected by the runtime and should not
      // appear as inputs or config options in the UI.
      $format = $paramSchema["format"] ?? NULL;
      $isHidden = $format === "hidden" || ($paramSchema["hidden"] ?? FALSE) === TRUE;
      if ($isHidden) {
        continue;
      }

      // Get entity-level configuration for this parameter.
      $entityConfig = $entityParameters[$paramName] ?? [];

      // Determine flags from entity config with system defaults.
      // Plugins no longer declare these - entity has full control.
      $configurable = $entityConfig["configurable"] ?? FALSE;
      $connectable = $entityConfig["connectable"] ?? FALSE;
      $required = $entityConfig["required"] ?? FALSE;

      // Get default value (entity overrides schema default).
      $defaultValue = $entityConfig["default"] ?? $paramSchema["default"] ?? NULL;

      // Build clean property schema (without flowdrop metadata).
      $cleanSchema = $this->cleanPropertySchema($paramSchema);

      // Add to inputSchema if connectable (shows as input port).
      if ($connectable) {
        $inputProperties[$paramName] = $cleanSchema;
        if ($required && !$configurable) {
          // Only required via input if no config fallback exists.
          $inputRequired[] = $paramName;
        }
      }

      // Add to configSchema if configurable (shows in config form).
      if ($configurable) {
        $configProperties[$paramName] = $cleanSchema;
        if ($required && !$connectable) {
          // Only required via config if no input fallback exists.
          $configRequired[] = $paramName;
        }

        // Add default value to config object.
        if ($defaultValue !== NULL) {
          $configDefaults[$paramName] = $defaultValue;
        }
      }
    }

    return [
      "inputSchema" => [
        "type" => "object",
        "properties" => $inputProperties,
        "required" => $inputRequired,
      ],
      "configSchema" => [
        "type" => "object",
        "properties" => $configProperties,
        "required" => $configRequired,
      ],
      "configDefaults" => $configDefaults,
    ];
  }

  /**
   * Clean property schema for API response.
   *
   * @param array<string, mixed> $schema
   *   The property schema.
   *
   * @return array<string, mixed>
   *   Clean schema suitable for API response.
   */
  protected function cleanPropertySchema(array $schema): array {
    return $schema;
  }

  /**
   * Filter output schema based on entity configuration.
   *
   * @param array<string, mixed> $outputSchema
   *   The plugin's output schema.
   * @param array<string, array<string, mixed>> $entityOutputs
   *   The entity's output configuration.
   *
   * @return array<string, mixed>
   *   Filtered output schema.
   */
  protected function filterOutputSchema(array $outputSchema, array $entityOutputs): array {
    $filteredProperties = [];
    $properties = $outputSchema["properties"] ?? [];

    foreach ($properties as $outputName => $outputDef) {
      $entityConfig = $entityOutputs[$outputName] ?? [];

      // Default to exposed=true if not configured.
      $exposed = $entityConfig["exposed"] ?? TRUE;

      if ($exposed) {
        $filteredProperties[$outputName] = $outputDef;
      }
    }

    return [
      "type" => "object",
      "properties" => $filteredProperties,
    ];
  }

  /**
   * Transform schema to NodePort array format.
   *
   * @param array<string, mixed> $schema
   *   The schema array.
   * @param string $portType
   *   The port type ('input' or 'output').
   *
   * @return array<\Drupal\flowdrop\DTO\NodeMetadata\NodePort>
   *   Array of NodePort objects.
   */
  protected function transformSchemaToPorts(array $schema, string $portType): array {
    $ports = [];
    $properties = $schema["properties"] ?? [];
    $requiredFields = $schema["required"] ?? [];

    foreach ($properties as $propertyName => $propertySchema) {
      // Extract the schema type, handling both string and array formats.
      // JSON Schema allows type to be an array (e.g., ["string", "null"]).
      $schemaType = $this->normalizeSchemaType($propertySchema["type"] ?? "string");

      $ports[] = new NodePort(
        id: $propertyName,
        name: $propertySchema["title"] ?? $propertyName,
        type: $portType,
        dataType: $this->mapSchemaTypeToDataType($schemaType),
        required: in_array($propertyName, $requiredFields, TRUE),
        description: $propertySchema["description"] ?? "",
        defaultValue: $propertySchema["default"] ?? NULL,
        // A port ships not-exposed (hidden until the author opts in) when its
        // schema declares `x-exposed-by-default: false`. Rides the same x-*
        // extension convention as `x-group` / `x-display-order`.
        exposedByDefault: PortExposure::isExposedByDefault($propertySchema),
        // Default render-position weight; reserved injected ports declare a
        // high `x-port-order` so they sort last unless the author reorders.
        displayOrder: (int) ($propertySchema["x-port-order"] ?? 0),
      );
    }

    return $ports;
  }

  /**
   * Normalize a JSON Schema type to a single string type.
   *
   * @param string|array<int, string> $type
   *   The JSON Schema type value (string or array of strings).
   *
   * @return string
   *   A normalized single type string.
   */
  protected function normalizeSchemaType(string|array $type): string {
    // If type is already a string, return it directly.
    if (is_string($type)) {
      return $type;
    }

    // If type is an array, find the first non-null type.
    // Common pattern: ["string", "null"] for nullable string.
    foreach ($type as $singleType) {
      if (is_string($singleType) && $singleType !== "null") {
        return $singleType;
      }
    }

    // Fallback to "string" if only "null" types or empty array.
    return "string";
  }

  /**
   * Map JSON schema types to NodeDataType.
   *
   * @param string $schemaType
   *   The JSON schema type.
   *
   * @return string
   *   The NodeDataType.
   */
  protected function mapSchemaTypeToDataType(string $schemaType): string {
    $typeMapping = [
      "string" => "string",
      "number" => "number",
      "integer" => "number",
      "boolean" => "boolean",
      "array" => "array",
      "object" => "json",
      "tool" => "tool",
      "mixed" => "mixed",
      "trigger" => "trigger",
      "branch" => "branch",
    ];

    return $typeMapping[$schemaType] ?? "string";
  }

  /**
   * Get parameter keys that are connectable.
   *
   * @param array<string, mixed> $parameterSchema
   *   The plugin's parameter schema.
   * @param array<string, array<string, mixed>> $entityParameters
   *   The entity's parameter configuration.
   *
   * @return array<string>
   *   Array of parameter keys that are connectable.
   */
  protected function getConnectableKeys(array $parameterSchema, array $entityParameters): array {
    $keys = [];
    $properties = $parameterSchema["properties"] ?? [];

    foreach ($properties as $name => $schema) {
      // Skip trigger - it has its own port.
      if ($name === ReservedName::PORT_TRIGGER) {
        continue;
      }

      // Skip hidden parameters.
      $format = $schema["format"] ?? NULL;
      $isHidden = $format === "hidden" || ($schema["hidden"] ?? FALSE) === TRUE;
      if ($isHidden) {
        continue;
      }

      $entityConfig = $entityParameters[$name] ?? [];
      $connectable = $entityConfig["connectable"] ?? FALSE;

      if ($connectable) {
        $keys[] = $name;
      }
    }

    return $keys;
  }

  /**
   * Get output keys that are exposed.
   *
   * @param array<string, mixed> $outputSchema
   *   The plugin's output schema.
   * @param array<string, array<string, mixed>> $entityOutputs
   *   The entity's output configuration.
   *
   * @return array<string>
   *   Array of output keys that are exposed.
   */
  protected function getExposedOutputKeys(array $outputSchema, array $entityOutputs): array {
    $keys = [];
    $properties = $outputSchema["properties"] ?? [];

    foreach ($properties as $name => $schema) {
      // Skip trigger - it has its own port.
      if ($name === ReservedName::PORT_TRIGGER) {
        continue;
      }

      $entityConfig = $entityOutputs[$name] ?? [];
      // Default to exposed=true if not configured.
      $exposed = $entityConfig["exposed"] ?? TRUE;

      if ($exposed) {
        $keys[] = $name;
      }
    }

    return $keys;
  }

}
