<?php

declare(strict_types=1);

namespace Drupal\flowdrop_node_type\Service;

use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop\Constants\ReservedName;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\FlowDropNodeProcessorInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop\Utility\NodeAnchor;
use Drupal\flowdrop_node_type\FlowDropNodeTypeInterface;

/**
 * Default NodeTypeGenerator implementation.
 *
 * @internal
 */
final class NodeTypeGenerator implements NodeTypeGeneratorInterface {

  /**
   * Machine-name pattern for a generated node-type id.
   *
   * The id comes from untrusted bundle metadata and becomes a config object
   * name (flowdrop_node_type.{id}); constrain it to the config machine-name
   * character set and a sane length before create()/save().
   */
  private const ID_PATTERN = '/^[a-z0-9_]+$/';

  /**
   * Maximum length of a generated node-type id.
   */
  private const ID_MAX_LENGTH = 64;

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly FlowDropNodeProcessorPluginManager $pluginManager,
  ) {}

  /**
   * {@inheritdoc}
   */
  public function generate(array $node): NodeTypeGenerationResult {
    $metadata = $node['data']['metadata'] ?? [];
    $id = NodeAnchor::readMetadata($metadata);
    $executorPlugin = (string) ($metadata['executor_plugin'] ?? '');

    if ($id === '') {
      return NodeTypeGenerationResult::rejected('', ['Node metadata has no node-type id.']);
    }
    if (strlen($id) > self::ID_MAX_LENGTH || preg_match(self::ID_PATTERN, $id) !== 1) {
      return NodeTypeGenerationResult::rejected($id, [
        sprintf('Node-type id "%s" is not a valid machine name (lowercase letters, numbers and underscores, max %d chars).', $id, self::ID_MAX_LENGTH),
      ]);
    }
    if ($executorPlugin === '') {
      return NodeTypeGenerationResult::rejected($id, ['Node metadata has no executor_plugin.']);
    }

    // Additive only: never overwrite an existing node type.
    $storage = $this->entityTypeManager->getStorage('flowdrop_node_type');
    $existing = $storage->load($id);
    if ($existing instanceof FlowDropNodeTypeInterface) {
      return NodeTypeGenerationResult::skipped($id, $existing);
    }

    // Resolve the backing plugin and verify its provider matches the namespace
    // prefix (no spoofed module attribution).
    $definition = $this->pluginManager->getDefinition($executorPlugin, FALSE);
    if (!is_array($definition)) {
      return NodeTypeGenerationResult::rejected($id, [
        sprintf('Processor plugin "%s" is not available.', $executorPlugin),
      ]);
    }
    $provider = (string) ($definition['provider'] ?? '');
    $prefix = str_contains($executorPlugin, ':') ? substr($executorPlugin, 0, (int) strpos($executorPlugin, ':')) : '';
    if ($prefix === '' || $provider === '' || $prefix !== $provider) {
      return NodeTypeGenerationResult::rejected($id, [
        sprintf('Executor plugin "%s" does not match its provider module "%s".', $executorPlugin, $provider),
      ]);
    }

    try {
      $plugin = $this->pluginManager->createInstance($executorPlugin);
    }
    catch (PluginException $e) {
      return NodeTypeGenerationResult::rejected($id, [
        sprintf('Processor plugin "%s" could not be instantiated: %s', $executorPlugin, $e->getMessage()),
      ]);
    }
    if (!$plugin instanceof FlowDropNodeProcessorInterface) {
      return NodeTypeGenerationResult::rejected($id, [
        sprintf('Plugin "%s" is not a node processor.', $executorPlugin),
      ]);
    }

    // The flags can only be inverted when the projection is present. A missing
    // projection key (not merely an empty one) means we'd have to fabricate
    // authoring intent, so refuse.
    foreach (['inputs', 'outputs', 'configSchema'] as $key) {
      if (!array_key_exists($key, $metadata)) {
        return NodeTypeGenerationResult::rejected($id, [
          sprintf('Node metadata is incomplete (missing "%s"); re-export the workflow with full metadata.', $key),
        ]);
      }
    }

    $parameterSchema = $plugin->getParameterSchema();
    $outputSchema = $plugin->getOutputSchema();
    $pluginParams = array_keys($parameterSchema['properties'] ?? []);
    $pluginOutputs = array_keys($outputSchema['properties'] ?? []);

    // Security gate: the file may not reference a port the plugin can't back.
    $reasons = $this->portsWithinPlugin($metadata, $pluginParams, $pluginOutputs, $executorPlugin);
    if ($reasons !== []) {
      return NodeTypeGenerationResult::rejected($id, $reasons);
    }

    $warnings = [];
    $parameters = $this->buildParameters($parameterSchema, $metadata, $warnings);
    $outputs = $this->buildOutputs($outputSchema, $metadata);

    $values = [
      'id' => $id,
      'label' => (string) ($metadata['label'] ?? $metadata['name'] ?? $id),
      'description' => (string) ($metadata['description'] ?? $plugin->getDescription()),
      'category' => (string) ($metadata['category'] ?? 'processing'),
      'icon' => (string) ($metadata['icon'] ?? 'mdi:cog'),
      'plugin_version' => (string) ($definition['version'] ?? $plugin->getVersion()),
      'enabled' => TRUE,
      'parameters' => $parameters,
      'outputs' => $outputs,
      'tags' => array_values($metadata['tags'] ?? []),
      'executor_plugin' => $executorPlugin,
      'visual_type' => (string) ($metadata['visual_type'] ?? 'default'),
      'supported_visual_types' => $metadata['supported_visual_types'] ?? ['default'],
      'unified_input_exposed' => (bool) ($metadata['unified_input_exposed'] ?? FALSE),
      'unified_output_exposed' => (bool) ($metadata['unified_output_exposed'] ?? FALSE),
      'tool_exposed' => (bool) ($metadata['tool_exposed'] ?? FALSE),
    ];

    $entity = $storage->create($values);
    if (!$entity instanceof FlowDropNodeTypeInterface) {
      return NodeTypeGenerationResult::rejected($id, ['Failed to instantiate the node type entity.']);
    }
    $entity->save();

    return NodeTypeGenerationResult::created($id, $entity, $warnings);
  }

  /**
   * Checks every metadata-referenced port exists on the live plugin.
   *
   * @param array<string, mixed> $metadata
   *   The node metadata.
   * @param array<int, string> $pluginParams
   *   Parameter names the plugin declares.
   * @param array<int, string> $pluginOutputs
   *   Output names the plugin declares.
   * @param string $executorPlugin
   *   The plugin id (for messages).
   *
   * @return array<int, string>
   *   Rejection reasons; empty when all referenced ports are backed.
   */
  private function portsWithinPlugin(array $metadata, array $pluginParams, array $pluginOutputs, string $executorPlugin): array {
    $reasons = [];
    foreach (array_keys($this->indexById($metadata['inputs'] ?? [])) as $id) {
      if (!in_array($id, $pluginParams, TRUE)) {
        $reasons[] = sprintf('Input port "%s" is not a parameter of plugin "%s".', $id, $executorPlugin);
      }
    }
    foreach (array_keys($metadata['configSchema']['properties'] ?? []) as $id) {
      if (!in_array($id, $pluginParams, TRUE)) {
        $reasons[] = sprintf('Config property "%s" is not a parameter of plugin "%s".', $id, $executorPlugin);
      }
    }
    foreach (array_keys($this->indexById($metadata['outputs'] ?? [])) as $id) {
      if (!in_array($id, $pluginOutputs, TRUE)) {
        $reasons[] = sprintf('Output port "%s" is not declared by plugin "%s".', $id, $executorPlugin);
      }
    }
    return $reasons;
  }

  /**
   * Reconstructs the parameters map (inverse of deriveSchemas()).
   *
   * @param array<string, mixed> $parameterSchema
   *   The live plugin parameter schema (authoritative property set).
   * @param array<string, mixed> $metadata
   *   The node metadata carrying the authoring flags.
   * @param array<int, string> $warnings
   *   Accumulator for non-blocking notes, by reference.
   *
   * @return array<string, array<string, mixed>>
   *   The parameters map keyed by parameter name.
   */
  private function buildParameters(array $parameterSchema, array $metadata, array &$warnings): array {
    $properties = $parameterSchema['properties'] ?? [];
    $schemaRequired = $parameterSchema['required'] ?? [];
    $inputs = $this->indexById($metadata['inputs'] ?? []);
    $configProps = $metadata['configSchema']['properties'] ?? [];
    $configRequired = $metadata['configSchema']['required'] ?? [];
    $config = $metadata['config'] ?? [];
    $configOnly = ReservedName::configSchemaOnlyParams();

    $parameters = [];
    foreach ($properties as $name => $schema) {
      $schema = is_array($schema) ? $schema : [];
      $hidden = ($schema['format'] ?? NULL) === 'hidden' || ($schema['hidden'] ?? FALSE) === TRUE;

      if ($hidden) {
        // Runtime-injected; never user-facing.
        $parameters[$name] = [
          'configurable' => FALSE,
          'connectable' => FALSE,
          'required' => in_array($name, $schemaRequired, TRUE),
        ];
        continue;
      }

      if (in_array($name, $configOnly, TRUE)) {
        // Dynamic-port definitions are always config-only, never connectable.
        $entry = ['configurable' => TRUE, 'connectable' => FALSE, 'required' => FALSE];
        $default = $config[$name] ?? ($configProps[$name]['default'] ?? ($schema['default'] ?? NULL));
        if ($default !== NULL) {
          $entry['default'] = $default;
        }
        $parameters[$name] = $entry;
        continue;
      }

      $connectable = isset($inputs[$name]);
      $configurable = array_key_exists($name, $configProps);
      $required = (($inputs[$name]['required'] ?? FALSE) === TRUE) || in_array($name, $configRequired, TRUE);

      $entry = [
        'configurable' => $configurable,
        'connectable' => $connectable,
        'required' => $required,
      ];

      $default = $config[$name] ?? ($configProps[$name]['default'] ?? ($schema['default'] ?? NULL));
      if ($default !== NULL) {
        if ($this->defaultMatchesType($default, $schema)) {
          $entry['default'] = $default;
        }
        else {
          $warnings[] = sprintf('Default for "%s" dropped (type mismatch with plugin schema).', $name);
          if (($schema['default'] ?? NULL) !== NULL) {
            $entry['default'] = $schema['default'];
          }
        }
      }

      $parameters[$name] = $entry;
    }

    return $parameters;
  }

  /**
   * Reconstructs the outputs map (inverse of filterOutputSchema()).
   *
   * @param array<string, mixed> $outputSchema
   *   The live plugin output schema (authoritative output set).
   * @param array<string, mixed> $metadata
   *   The node metadata carrying the exposed flags.
   *
   * @return array<string, array{exposed: bool}>
   *   The outputs map keyed by output name.
   */
  private function buildOutputs(array $outputSchema, array $metadata): array {
    $exposed = $this->indexById($metadata['outputs'] ?? []);
    $outputs = [];
    foreach (array_keys($outputSchema['properties'] ?? []) as $name) {
      $outputs[$name] = ['exposed' => isset($exposed[$name])];
    }
    return $outputs;
  }

  /**
   * Indexes a list of ports by their id.
   *
   * @param array<int, array<string, mixed>> $ports
   *   The ports list (each with an 'id').
   *
   * @return array<string, array<string, mixed>>
   *   Ports keyed by id; entries without a string id are skipped.
   */
  private function indexById(array $ports): array {
    $indexed = [];
    foreach ($ports as $port) {
      $id = is_array($port) ? ($port['id'] ?? NULL) : NULL;
      if (is_string($id) && $id !== '') {
        $indexed[$id] = $port;
      }
    }
    return $indexed;
  }

  /**
   * Lightweight JSON-Schema type check for a default value.
   *
   * @param mixed $value
   *   The candidate default.
   * @param array<string, mixed> $schema
   *   The property schema.
   */
  private function defaultMatchesType(mixed $value, array $schema): bool {
    $type = $schema['type'] ?? NULL;
    if ($type === NULL) {
      return TRUE;
    }
    $types = is_array($type) ? $type : [$type];
    foreach ($types as $candidate) {
      $ok = match ($candidate) {
        'string' => is_string($value),
        'integer' => is_int($value),
        'number' => is_int($value) || is_float($value),
        'boolean' => is_bool($value),
        'array', 'object' => is_array($value),
        'null' => $value === NULL,
        default => TRUE,
      };
      if ($ok) {
        return TRUE;
      }
    }
    return FALSE;
  }

}
