<?php

declare(strict_types=1);

namespace Drupal\flowdrop_workflow\Service;

use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\FlowDropNodeProcessorInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop\Utility\NodeAnchor;
use Drupal\flowdrop_node_type\Service\NodeMetadataResolver;
use Drupal\flowdrop_workflow\FlowDropWorkflowInterface;

/**
 * Default WorkflowSchemaBuilder implementation.
 *
 * @internal
 */
final class WorkflowSchemaBuilder implements WorkflowSchemaBuilderInterface {

  /**
   * Closed contract-key set for FlowDrop Schema fragments.
   *
   * Keys outside this set (including annotations like title/description)
   * are stripped by pickContractKeys().
   */
  private const CONTRACT_KEYS = [
    'type',
    'enum',
    'format',
    'default',
    'required',
    'properties',
    'items',
    'minimum',
    'maximum',
    'minLength',
    'maxLength',
    'pattern',
  ];

  public function __construct(
    private readonly FlowDropNodeProcessorPluginManager $pluginManager,
    private readonly LoggerChannelInterface $logger,
    private readonly NodeMetadataResolver $metadataResolver,
  ) {}

  /**
   * {@inheritdoc}
   */
  public function build(
    FlowDropWorkflowInterface $workflow,
    array $ports,
    string $direction,
    array &$visited = [],
  ): array {
    $workflowId = (string) ($workflow->id() ?? '(new)');

    // Recursion guard for workflow-as-port composition.
    if (isset($visited[$workflowId])) {
      $this->logger->warning('Workflow schema build cycle detected: @path', [
        '@path' => implode(' -> ', array_keys($visited)) . ' -> ' . $workflowId,
      ]);
      return ['type' => 'object', 'properties' => []];
    }
    $visited[$workflowId] = TRUE;

    $nodesById = $this->indexNodes($workflow->getNodes());
    $properties = [];

    foreach ($ports as $entry) {
      $name = $entry['name'] ?? '';
      $nodeId = $entry['node_id'] ?? '';
      $portName = $entry['port'] ?? '';

      if ($name === '' || $nodeId === '' || $portName === '') {
        continue;
      }

      if (!isset($nodesById[$nodeId])) {
        $this->logger->warning(
          'Exposure entry dropped (node missing) on workflow @workflow_id: name=@name node=@node port=@port',
          [
            '@workflow_id' => $workflowId,
            '@name' => $name,
            '@node' => $nodeId,
            '@port' => $portName,
          ]
        );
        continue;
      }

      $portSchema = $this->resolvePortSchema($workflowId, $name, $nodesById[$nodeId], $portName, $direction);
      if ($portSchema === NULL) {
        continue;
      }

      $properties[$name] = $this->pickContractKeys($portSchema);
    }

    // Pop the workflow off the visited path so sibling branches can re-enter
    // it.
    unset($visited[$workflowId]);

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

  /**
   * {@inheritdoc}
   */
  public function pickContractKeys(array $fragment): array {
    $filtered = [];
    foreach ($fragment as $key => $value) {
      if (!in_array($key, self::CONTRACT_KEYS, TRUE)) {
        continue;
      }

      if ($key === 'properties' && is_array($value)) {
        $nested = [];
        foreach ($value as $propName => $propFragment) {
          if (is_array($propFragment)) {
            $nested[$propName] = $this->pickContractKeys($propFragment);
          }
        }
        $filtered['properties'] = $nested;
        continue;
      }

      if ($key === 'items' && is_array($value)) {
        $filtered['items'] = $this->pickContractKeys($value);
        continue;
      }

      if ($key === 'default') {
        $sanitised = $this->sanitiseDefault($value);
        if (!$sanitised['ok']) {
          $this->logger->warning('Default value dropped from schema snapshot (unsupported shape).');
          continue;
        }
        $filtered['default'] = $sanitised['value'];
        continue;
      }

      $filtered[$key] = $value;
    }
    return $filtered;
  }

  /**
   * Validates that a default value is JSON-serialisable.
   *
   * Accepts scalars or one-level arrays/objects of scalars. Returns a tagged
   * result so callers don't need a sentinel value.
   *
   * @param mixed $value
   *   The default value to sanitise.
   *
   * @return array{ok: bool, value: mixed}
   *   Tagged result; 'ok' is FALSE when the value was unacceptable.
   */
  private function sanitiseDefault(mixed $value): array {
    if ($value === NULL || is_scalar($value)) {
      return ['ok' => TRUE, 'value' => $value];
    }
    if (is_array($value)) {
      foreach ($value as $element) {
        if ($element !== NULL && !is_scalar($element)) {
          return ['ok' => FALSE, 'value' => NULL];
        }
      }
      return ['ok' => TRUE, 'value' => $value];
    }
    if (is_object($value)) {
      $vars = get_object_vars($value);
      if ($vars === []) {
        return ['ok' => FALSE, 'value' => NULL];
      }
      foreach ($vars as $element) {
        if ($element !== NULL && !is_scalar($element)) {
          return ['ok' => FALSE, 'value' => NULL];
        }
      }
      return ['ok' => TRUE, 'value' => $vars];
    }
    return ['ok' => FALSE, 'value' => NULL];
  }

  /**
   * Indexes nodes by their identifier.
   *
   * @param array<int, array<string, mixed>> $nodes
   *   The nodes to index.
   *
   * @return array<string, array<string, mixed>>
   *   Nodes keyed by identifier.
   */
  private function indexNodes(array $nodes): array {
    $indexed = [];
    foreach ($nodes as $node) {
      $id = $node['id'] ?? NULL;
      if (is_string($id) && $id !== '') {
        $indexed[$id] = $node;
      }
    }
    return $indexed;
  }

  /**
   * Resolves a single port to its FlowDrop Schema fragment.
   *
   * @return array<string, mixed>|null
   *   The port schema fragment, or NULL when no schema is available.
   */
  private function resolvePortSchema(
    string $workflowId,
    string $exposureName,
    array $node,
    string $portName,
    string $direction,
  ): ?array {
    $nodeTypeId = NodeAnchor::read($node);
    $pluginId = (is_string($nodeTypeId) && $nodeTypeId !== ''
      ? $this->metadataResolver->resolveExecutorPlugin($nodeTypeId)
      : NULL) ?? '';
    if ($pluginId === '') {
      $this->logger->warning('Exposure entry @name on workflow @workflow_id: node has no executor_plugin', [
        '@name' => $exposureName,
        '@workflow_id' => $workflowId,
      ]);
      return NULL;
    }

    try {
      /** @var \Drupal\flowdrop\Plugin\FlowDropNodeProcessor\FlowDropNodeProcessorInterface $plugin */
      $plugin = $this->pluginManager->createInstance($pluginId);
    }
    catch (PluginException $e) {
      $this->logger->warning(
        'Source plugin @plugin_id missing for exposure @name on workflow @workflow_id: @message',
        [
          '@plugin_id' => $pluginId,
          '@name' => $exposureName,
          '@workflow_id' => $workflowId,
          '@message' => $e->getMessage(),
        ]
      );
      return NULL;
    }

    if (!$plugin instanceof FlowDropNodeProcessorInterface) {
      return NULL;
    }

    $schema = $direction === 'input'
      ? $plugin->getParameterSchema()
      : $plugin->getOutputSchema();

    $properties = $schema['properties'] ?? [];
    $portFragment = $properties[$portName] ?? NULL;

    if (!is_array($portFragment)) {
      $this->logger->warning(
        'Exposure entry @name dropped on workflow @workflow_id: port @port not declared by plugin @plugin_id',
        [
          '@name' => $exposureName,
          '@workflow_id' => $workflowId,
          '@port' => $portName,
          '@plugin_id' => $pluginId,
        ]
      );
      return NULL;
    }

    return $portFragment;
  }

}
