<?php

declare(strict_types=1);

namespace Drupal\flowdrop_node_type\Service;

use Drupal\Core\StringTranslation\StringTranslationTrait;

/**
 * Derives a JSON-Forms-style uiSchema from a node's configSchema.
 *
 * The configSchema stays a pure JSON Schema; layout intent lives in a sibling
 * uiSchema the editor's config form renders (VerticalLayout / Group / Control).
 * Grouping is driven by an `x-group` hint on each configSchema property:
 *
 * - Untagged properties land in the `default` group and render inline (no
 *   fieldset), so an ungrouped form looks exactly as it did before.
 * - Properties tagged with a known group id (see the registry) are collected
 *   into a collapsible Group, ordered by the registry.
 * - An unknown `x-group` value falls back to `default` (no stray fieldset).
 *
 * Because the generator walks every property, a new param nobody tagged is
 * never dropped — it simply appears inline. When only the `default` group is
 * populated, no uiSchema is emitted at all and the form stays on its flat path.
 *
 * The static structure (groups, baseline collapsed state) is node-type wide and
 * built by {@see self::generate()}. The per-instance "open the group when the
 * user set one of its fields" behaviour is applied at enrich-on-read time by
 * {@see self::applyInstanceOpenState()}, which has the saved config in scope.
 *
 * @see \Drupal\flowdrop_node_type\Service\NodeMetadataResolver
 * @see docs/development/reserved-names.md
 *
 * @internal
 *   Not part of the public API; may change without notice.
 */
class UiSchemaGenerator {

  use StringTranslationTrait;

  /**
   * Group id for ungrouped properties: rendered inline, no fieldset.
   */
  public const GROUP_DEFAULT = 'default';

  /**
   * Group id for general node props (title, description, visual type).
   *
   * Title and description are not merely cosmetic: for a tool-exposed node the
   * description is the tool description the model reads at runtime.
   */
  public const GROUP_GENERAL = 'general';

  /**
   * Group id for runtime-behaviour props (retry budget, future timeout, etc.).
   */
  public const GROUP_EXECUTION = 'execution';

  /**
   * Group id for port order + exposure props (the injected `ports` widget).
   */
  public const GROUP_PORTS = 'ports';

  /**
   * The scope prefix every Control uses to reference a configSchema property.
   */
  private const SCOPE_PREFIX = '#/properties/';

  /**
   * Build the uiSchema for a configSchema, or NULL when none is needed.
   *
   * @param array<string, mixed> $configSchema
   *   The node type's configSchema (a JSON Schema object).
   *
   * @return array<string, mixed>|null
   *   A VerticalLayout uiSchema, or NULL when every property is ungrouped (the
   *   form should stay on its flat rendering path).
   */
  public function generate(array $configSchema): ?array {
    $properties = $configSchema['properties'] ?? [];
    if (!is_array($properties) || $properties === []) {
      return NULL;
    }

    $registry = $this->groupRegistry();

    // Bucket property keys by group, remembering each one's display order.
    $buckets = [];
    foreach ($properties as $key => $schema) {
      $group = is_array($schema) ? ($schema['x-group'] ?? self::GROUP_DEFAULT) : self::GROUP_DEFAULT;
      if (!isset($registry[$group])) {
        $group = self::GROUP_DEFAULT;
      }
      $order = (is_array($schema) ? ($schema['x-display-order'] ?? 0) : 0);
      $buckets[$group][] = ['key' => (string) $key, 'order' => (int) $order];
    }

    // Nothing tagged → no layout intent → stay flat (backward compatible).
    if (array_keys($buckets) === [self::GROUP_DEFAULT]) {
      return NULL;
    }

    // Order the groups by the registry, and each group's fields by display
    // order. usort/uksort are stable as of PHP 8.0, so ties keep insertion
    // order (schema definition order).
    uksort($buckets, static fn(string $a, string $b): int => ($registry[$a]['order'] ?? 0) <=> ($registry[$b]['order'] ?? 0));

    $elements = [];
    foreach ($buckets as $groupId => $items) {
      usort($items, static fn(array $a, array $b): int => $a['order'] <=> $b['order']);

      $controls = array_map(
        static fn(array $item): array => [
          'type' => 'Control',
          'scope' => self::SCOPE_PREFIX . $item['key'],
        ],
        $items
      );

      $definition = $registry[$groupId];
      if (empty($definition['wrap'])) {
        // Default group: emit its controls inline, no wrapper.
        array_push($elements, ...$controls);
        continue;
      }

      $group = [
        'type' => 'Group',
        'label' => $definition['label'],
        'collapsible' => $definition['collapsible'] ?? TRUE,
        // Explicit: FormFieldset treats a missing defaultOpen as open.
        'defaultOpen' => $definition['defaultOpen'] ?? FALSE,
        'elements' => $controls,
      ];
      if (!empty($definition['icon'])) {
        $group['icon'] = $definition['icon'];
      }
      $elements[] = $group;
    }

    return [
      'type' => 'VerticalLayout',
      'elements' => $elements,
    ];
  }

  /**
   * Open a group for this instance when one of its fields is customised.
   *
   * The static uiSchema ships every group collapsed. A collapsed group hiding a
   * value the user set is a footgun, so for a stored node we open any group
   * with a member whose saved value differs from its schema default. This is
   * the only part of the uiSchema that depends on the node instance.
   *
   * @param array<string, mixed> $uiSchema
   *   The generated uiSchema (a VerticalLayout).
   * @param array<string, mixed> $config
   *   The node instance's saved config values (`data.config`).
   * @param array<string, mixed> $configSchema
   *   The configSchema, used to read each property's default.
   *
   * @return array<string, mixed>
   *   The uiSchema with group defaultOpen flags adjusted for this instance.
   */
  public function applyInstanceOpenState(array $uiSchema, array $config, array $configSchema): array {
    if (($uiSchema['type'] ?? NULL) !== 'VerticalLayout' || !is_array($uiSchema['elements'] ?? NULL)) {
      return $uiSchema;
    }

    $properties = is_array($configSchema['properties'] ?? NULL) ? $configSchema['properties'] : [];

    foreach ($uiSchema['elements'] as &$element) {
      if (!is_array($element) || ($element['type'] ?? NULL) !== 'Group') {
        continue;
      }
      foreach ($this->controlKeys($element['elements'] ?? []) as $key) {
        if ($this->isCustomised($key, $config, $properties)) {
          $element['defaultOpen'] = TRUE;
          break;
        }
      }
    }
    unset($element);

    return $uiSchema;
  }

  /**
   * The opinionated, closed group registry: id => layout definition.
   *
   * @return array<string, array<string, mixed>>
   *   Keyed by group id. `order` sets render position; `wrap` FALSE means the
   *   group renders inline (no fieldset); `label`/`collapsible`/`defaultOpen`
   *   describe the fieldset for wrapped groups.
   */
  protected function groupRegistry(): array {
    return [
      self::GROUP_DEFAULT => ['order' => 0, 'wrap' => FALSE],
      self::GROUP_GENERAL => [
        'order' => 80,
        'wrap' => TRUE,
        'label' => $this->t('General'),
        'collapsible' => TRUE,
        'defaultOpen' => FALSE,
        'icon' => 'heroicons:cog-6-tooth',
      ],
      self::GROUP_EXECUTION => [
        'order' => 100,
        'wrap' => TRUE,
        'label' => $this->t('Execution'),
        'collapsible' => TRUE,
        'defaultOpen' => FALSE,
        'icon' => 'heroicons:bolt',
      ],
      self::GROUP_PORTS => [
        'order' => 120,
        'wrap' => TRUE,
        'label' => $this->t('Ports'),
        'collapsible' => TRUE,
        'defaultOpen' => FALSE,
        'icon' => 'heroicons:arrows-right-left',
      ],
    ];
  }

  /**
   * Extract the property keys referenced by a group's Control elements.
   *
   * @param array<int, mixed> $elements
   *   The group's child elements.
   *
   * @return array<int, string>
   *   The configSchema property keys, in element order.
   */
  protected function controlKeys(array $elements): array {
    $keys = [];
    foreach ($elements as $element) {
      if (!is_array($element) || ($element['type'] ?? NULL) !== 'Control') {
        continue;
      }
      $scope = $element['scope'] ?? '';
      if (is_string($scope) && str_starts_with($scope, self::SCOPE_PREFIX)) {
        $keys[] = substr($scope, strlen(self::SCOPE_PREFIX));
      }
    }
    return $keys;
  }

  /**
   * Whether a saved value differs from the property's schema default.
   *
   * Unset, NULL and empty-string values count as "not customised", so a node
   * left at its defaults keeps its groups collapsed.
   *
   * @param string $key
   *   The configSchema property key.
   * @param array<string, mixed> $config
   *   The node instance's saved config values.
   * @param array<string, mixed> $properties
   *   The configSchema properties (for reading defaults).
   *
   * @return bool
   *   TRUE when the instance has set a meaningful, non-default value.
   */
  protected function isCustomised(string $key, array $config, array $properties): bool {
    if (!array_key_exists($key, $config)) {
      return FALSE;
    }
    $value = $config[$key];
    if ($value === NULL || $value === '') {
      return FALSE;
    }
    $default = (is_array($properties[$key] ?? NULL)) ? ($properties[$key]['default'] ?? NULL) : NULL;
    return $value !== $default;
  }

}
