<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_node_type\Unit\Service;

use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\FlowDropNodeProcessorInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop_node_type\FlowDropNodeTypeInterface;
use Drupal\flowdrop_node_type\Service\NodeTypeGenerationResult;
use Drupal\flowdrop_node_type\Service\NodeTypeGenerator;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;

/**
 * Tests the NodeTypeGenerator rejection and skip paths.
 *
 * The CREATED path (real entity write + reconstructed parameter/output maps) is
 * covered by the kernel test; here we pin the guards that must hold before
 * anything is persisted: identity, additive-only skip, plugin resolution,
 * provider anti-spoofing, metadata completeness and the port-within-plugin
 * security gate.
 *
 * @coversDefaultClass \Drupal\flowdrop_node_type\Service\NodeTypeGenerator
 * @group flowdrop_node_type
 */
class NodeTypeGeneratorTest extends UnitTestCase {

  use ProphecyTrait;

  private const PLUGIN = 'flowdrop_node_processor:calculator';

  /**
   * The mocked node-type storage.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<EntityStorageInterface>
   */
  private $storage;

  /**
   * The mocked plugin manager.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<FlowDropNodeProcessorPluginManager>
   */
  private $pluginManager;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->storage = $this->prophesize(EntityStorageInterface::class);
    $this->pluginManager = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
  }

  /**
   * Builds the generator wired to the prophecies.
   */
  private function generator(): NodeTypeGenerator {
    $etm = $this->prophesize(EntityTypeManagerInterface::class);
    $etm->getStorage('flowdrop_node_type')->willReturn($this->storage->reveal());
    return new NodeTypeGenerator($etm->reveal(), $this->pluginManager->reveal());
  }

  /**
   * Builds a workflow node with the given metadata.
   *
   * @param array<string, mixed> $metadata
   *   The node's data.metadata block.
   *
   * @return array<string, mixed>
   *   The node.
   */
  private function node(array $metadata): array {
    return ['id' => 'n1', 'data' => ['metadata' => $metadata]];
  }

  /**
   * Returns metadata that resolves and is structurally complete.
   *
   * @return array<string, mixed>
   *   The metadata.
   */
  private function completeMetadata(): array {
    return [
      'node_type_id' => 'calculator',
      'executor_plugin' => self::PLUGIN,
      'inputs' => [['id' => 'values']],
      'configSchema' => ['type' => 'object', 'properties' => ['operation' => [], 'precision' => []]],
      'outputs' => [['id' => 'result']],
    ];
  }

  /**
   * Stubs a resolvable calculator plugin (definition + instance).
   */
  private function stubResolvablePlugin(): void {
    $this->pluginManager->getDefinition(self::PLUGIN, FALSE)->willReturn([
      'provider' => 'flowdrop_node_processor',
      'version' => '1.0.0',
    ]);
    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->getParameterSchema()->willReturn([
      'properties' => [
        'values' => ['type' => 'array'],
        'operation' => ['type' => 'string'],
        'precision' => ['type' => 'integer'],
      ],
    ]);
    $processor->getOutputSchema()->willReturn([
      'properties' => ['result' => ['type' => 'number'], 'operation' => [], 'values' => [], 'precision' => []],
    ]);
    $this->pluginManager->createInstance(self::PLUGIN)->willReturn($processor->reveal());
  }

  /**
   * Missing node-type id is rejected.
   */
  public function testRejectsMissingId(): void {
    $result = $this->generator()->generate($this->node(['executor_plugin' => self::PLUGIN]));
    $this->assertSame(NodeTypeGenerationResult::REJECTED, $result->status);
  }

  /**
   * An id that isn't a valid machine name is rejected before any storage call.
   *
   * The id comes from untrusted bundle metadata and becomes a config object
   * name, so it must be constrained to the machine-name character set and a
   * sane length before create()/save() — and rejected without even probing
   * storage for a collision.
   *
   * @dataProvider providerInvalidIds
   */
  public function testRejectsInvalidMachineNameId(string $id): void {
    $this->storage->load(Argument::any())->shouldNotBeCalled();
    $this->storage->create(Argument::cetera())->shouldNotBeCalled();

    $metadata = $this->completeMetadata();
    $metadata['node_type_id'] = $id;
    $result = $this->generator()->generate($this->node($metadata));

    $this->assertSame(NodeTypeGenerationResult::REJECTED, $result->status);
    $this->assertStringContainsString('machine name', strtolower(implode(' ', $result->reasons)));
  }

  /**
   * Invalid machine-name ids.
   *
   * @return array<string, array{string}>
   *   Test cases.
   */
  public static function providerInvalidIds(): array {
    return [
      'dotted config path' => ['evil.config.name'],
      'slash' => ['foo/bar'],
      'uppercase' => ['Calculator'],
      'hyphen' => ['my-node'],
      'space' => ['my node'],
      'too long' => [str_repeat('a', 65)],
    ];
  }

  /**
   * An existing node type is skipped, never overwritten.
   */
  public function testSkipsExisting(): void {
    $existing = $this->prophesize(FlowDropNodeTypeInterface::class)->reveal();
    $this->storage->load('calculator')->willReturn($existing);
    $this->storage->create(Argument::cetera())->shouldNotBeCalled();

    $result = $this->generator()->generate($this->node($this->completeMetadata()));

    $this->assertSame(NodeTypeGenerationResult::SKIPPED, $result->status);
    $this->assertSame($existing, $result->entity);
  }

  /**
   * An unavailable processor plugin is rejected.
   */
  public function testRejectsMissingPlugin(): void {
    $this->storage->load('calculator')->willReturn(NULL);
    $this->pluginManager->getDefinition(self::PLUGIN, FALSE)->willReturn(NULL);

    $result = $this->generator()->generate($this->node($this->completeMetadata()));

    $this->assertSame(NodeTypeGenerationResult::REJECTED, $result->status);
  }

  /**
   * A provider that doesn't match the plugin namespace prefix is rejected.
   */
  public function testRejectsProviderSpoof(): void {
    $this->storage->load('calculator')->willReturn(NULL);
    $this->pluginManager->getDefinition(self::PLUGIN, FALSE)->willReturn([
      'provider' => 'some_other_module',
      'version' => '1.0.0',
    ]);

    $result = $this->generator()->generate($this->node($this->completeMetadata()));

    $this->assertSame(NodeTypeGenerationResult::REJECTED, $result->status);
    $this->assertStringContainsString('provider', strtolower(implode(' ', $result->reasons)));
  }

  /**
   * A plugin that throws on instantiation is rejected.
   */
  public function testRejectsUninstantiablePlugin(): void {
    $this->storage->load('calculator')->willReturn(NULL);
    $this->pluginManager->getDefinition(self::PLUGIN, FALSE)->willReturn([
      'provider' => 'flowdrop_node_processor',
      'version' => '1.0.0',
    ]);
    $this->pluginManager->createInstance(self::PLUGIN)->willThrow(new PluginNotFoundException(self::PLUGIN));

    $result = $this->generator()->generate($this->node($this->completeMetadata()));

    $this->assertSame(NodeTypeGenerationResult::REJECTED, $result->status);
  }

  /**
   * Metadata missing a projection key (here configSchema) is rejected.
   */
  public function testRejectsIncompleteMetadata(): void {
    $this->storage->load('calculator')->willReturn(NULL);
    $this->stubResolvablePlugin();
    $metadata = $this->completeMetadata();
    unset($metadata['configSchema']);

    $result = $this->generator()->generate($this->node($metadata));

    $this->assertSame(NodeTypeGenerationResult::REJECTED, $result->status);
    $this->assertStringContainsString('incomplete', strtolower(implode(' ', $result->reasons)));
  }

  /**
   * A port the live plugin can't back is rejected (no contract widening).
   */
  public function testRejectsPortNotInPlugin(): void {
    $this->storage->load('calculator')->willReturn(NULL);
    $this->stubResolvablePlugin();
    $metadata = $this->completeMetadata();
    $metadata['inputs'][] = ['id' => 'evil_injected_param'];

    $result = $this->generator()->generate($this->node($metadata));

    $this->assertSame(NodeTypeGenerationResult::REJECTED, $result->status);
    $this->assertStringContainsString('evil_injected_param', implode(' ', $result->reasons));
  }

}
