<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_workflow\Unit\Service;

use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\flowdrop\DTO\ValidationResult;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\FlowDropNodeProcessorInterface;
use Drupal\flowdrop\DTO\NodeMetadata\NodeMetadata;
use Drupal\Tests\flowdrop_workflow\Unit\Service\Fixtures\TerminalStubProcessor;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop_node_type\Service\NodeMetadataResolver;
use Drupal\flowdrop_workflow\Service\WorkflowValidator;
use Drupal\flowdrop_workflow\Service\WorkflowValidatorInterface;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;

/**
 * Tests the WorkflowValidator service.
 *
 * Covers the cheap, save-hot-path rules (R1 plugin existence, R4 exposure
 * integrity, R5 structural limits) the validator centralises for the form and
 * the API, plus the laziness/memoisation contract: existence is a definition
 * lookup, and a plugin is constructed at most once and only for exposed ports.
 *
 * @coversDefaultClass \Drupal\flowdrop_workflow\Service\WorkflowValidator
 * @group flowdrop_workflow
 */
class WorkflowValidatorTest extends UnitTestCase {

  use ProphecyTrait;

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

  /**
   * The mocked node metadata resolver.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<NodeMetadataResolver>
   */
  private $metadataResolver;

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

  /**
   * Builds the validator with the current plugin-manager prophecy.
   */
  private function validator(): WorkflowValidator {
    return new WorkflowValidator(
      $this->pluginManager->reveal(),
      $this->metadataResolver->reveal(),
    );
  }

  /**
   * Returns a node anchored to a node type that resolves to a plugin.
   *
   * Slim nodes persist only the node-type id anchor; the validator resolves the
   * executor plugin from the node type (the source of truth), never from stored
   * metadata. The plugin id doubles as the node-type id here for brevity.
   *
   * @return array<string, mixed>
   *   A node array.
   */
  private function node(string $id, string $pluginId = ''): array {
    $metadata = [];
    if ($pluginId !== '') {
      $metadata['node_type_id'] = $pluginId;
      $this->metadataResolver->resolveExecutorPlugin($pluginId)->willReturn($pluginId);
      // R6 config validation resolves the config schema from the node type; the
      // R1/R4/R5 tests here don't exercise it, so leave it unresolved.
      $this->metadataResolver->buildForNodeTypeId($pluginId)->willReturn(NULL);
    }
    return [
      'id' => $id,
      'data' => ['metadata' => $metadata],
    ];
  }

  /**
   * Returns an exposure entry.
   *
   * @return array{name: string, node_id: string, port: string}
   *   An exposure entry.
   */
  private function entry(string $name, string $nodeId, string $port): array {
    return ['name' => $name, 'node_id' => $nodeId, 'port' => $port];
  }

  /**
   * Asserts the result has exactly one error with the given code and locator.
   */
  private function assertSingleError(ValidationResult $result, string $code, string $locator): void {
    $this->assertFalse($result->isValid());
    $this->assertCount(1, $result->getErrors());
    $error = $result->getErrors()[0];
    $this->assertSame($code, $error->getCode());
    $this->assertSame($locator, $error->getParameter());
  }

  /**
   * A node wired after a terminal node yields a non-blocking warning.
   *
   * @covers ::validate
   */
  public function testTerminalNodeDownstreamWarns(): void {
    $this->pluginManager->hasDefinition('stop_proc')->willReturn(TRUE);
    $this->pluginManager->hasDefinition('noop_proc')->willReturn(TRUE);

    // Terminal-ness is read from the definition's class, without constructing
    // the plugin (createInstance is never called for this check).
    $this->pluginManager->getDefinition('stop_proc', FALSE)
      ->willReturn(['class' => TerminalStubProcessor::class]);
    $this->pluginManager->getDefinition('noop_proc', FALSE)
      ->willReturn(['class' => FlowDropNodeProcessorInterface::class]);
    $this->pluginManager->createInstance(Argument::cetera())->shouldNotBeCalled();

    $nodes = [$this->node('stop1', 'stop_proc'), $this->node('after1', 'noop_proc')];
    $edges = [['source' => 'stop1', 'target' => 'after1']];

    $result = $this->validator()->validate($nodes, $edges, [], []);

    $this->assertTrue($result->isValid(), 'A dead-downstream node is a warning, not an error.');
    $warnings = $result->getWarnings();
    $this->assertCount(1, $warnings);
    $this->assertSame('edge.0', $warnings[0]->getParameter());
    $this->assertStringContainsString('after1', $warnings[0]->getMessage());
    $this->assertStringContainsString('stop1', $warnings[0]->getMessage());
  }

  /**
   * A structurally valid workflow with no exposures constructs no plugins.
   */
  public function testValidWorkflowConstructsNoPlugins(): void {
    $this->pluginManager->hasDefinition('calc')->willReturn(TRUE);
    $this->pluginManager->createInstance(Argument::cetera())->shouldNotBeCalled();

    $result = $this->validator()->validate([$this->node('n1', 'calc')], [], [], []);

    $this->assertTrue($result->isValid());
  }

  /**
   * R1: a node naming an unresolvable plugin is an error.
   */
  public function testR1MissingPlugin(): void {
    $this->pluginManager->hasDefinition('ghost')->willReturn(FALSE);

    $result = $this->validator()->validate([$this->node('n1', 'ghost')], [], [], []);

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_PLUGIN_MISSING, 'node.n1');
  }

  /**
   * R1: a node with no declared executor plugin is left to a later phase.
   */
  public function testR1EmptyPluginIsSkipped(): void {
    $this->pluginManager->hasDefinition(Argument::any())->shouldNotBeCalled();

    $result = $this->validator()->validate([$this->node('n1', '')], [], [], []);

    $this->assertTrue($result->isValid());
  }

  /**
   * R5: a node with no identifier is an error located by position.
   */
  public function testR5NodeMissingId(): void {
    $result = $this->validator()->validate([['data' => ['metadata' => []]]], [], [], []);

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_NODE_MISSING_ID, 'node.0');
  }

  /**
   * R5: exceeding the node limit is an error.
   */
  public function testR5TooManyNodes(): void {
    $nodes = [];
    for ($i = 0; $i <= WorkflowValidatorInterface::MAX_NODES; $i++) {
      $nodes[] = $this->node('n' . $i);
    }

    $result = $this->validator()->validate($nodes, [], [], []);

    $codes = array_map(static fn ($e) => $e->getCode(), $result->getErrors());
    $this->assertContains(WorkflowValidatorInterface::CODE_TOO_MANY_NODES, $codes);
  }

  /**
   * R5: exceeding the edge limit is an error.
   */
  public function testR5TooManyEdges(): void {
    $edges = array_fill(0, WorkflowValidatorInterface::MAX_EDGES + 1, ['source' => 'a', 'target' => 'b']);

    // Provide the referenced endpoints so only the count error fires; without
    // them R8 would flag every edge as dangling.
    $result = $this->validator()->validate([$this->node('a'), $this->node('b')], $edges, [], []);

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_TOO_MANY_EDGES, 'workflow');
  }

  /**
   * R4: an exposed name outside [a-z0-9_-]+ is an error.
   */
  public function testR4NameFormat(): void {
    $this->pluginManager->hasDefinition('calc')->willReturn(TRUE);
    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->getParameterSchema()->willReturn(['properties' => ['result' => ['type' => 'string']]]);
    $this->pluginManager->createInstance('calc')->willReturn($processor->reveal());

    $result = $this->validator()->validate(
      [$this->node('n1', 'calc')],
      [],
      [$this->entry('Bad Name', 'n1', 'result')],
      [],
    );

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_NAME_FORMAT, 'schema.input.0');
  }

  /**
   * R4: a duplicated exposed name on the same side is an error.
   */
  public function testR4DuplicateName(): void {
    $this->pluginManager->hasDefinition('calc')->willReturn(TRUE);
    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->getParameterSchema()->willReturn(['properties' => ['a' => [], 'b' => []]]);
    $this->pluginManager->createInstance('calc')->willReturn($processor->reveal());

    $result = $this->validator()->validate(
      [$this->node('n1', 'calc')],
      [],
      [$this->entry('dup', 'n1', 'a'), $this->entry('dup', 'n1', 'b')],
      [],
    );

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_NAME_DUPLICATE, 'schema.input.1');
  }

  /**
   * R4: an exposure referencing a missing node is an error.
   */
  public function testR4NodeMissing(): void {
    $result = $this->validator()->validate(
      [],
      [],
      [$this->entry('foo', 'gone', 'result')],
      [],
    );

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_NODE_MISSING, 'schema.input.0');
  }

  /**
   * R4: an exposure referencing an undeclared port is an error (output side).
   */
  public function testR4PortMissingOnOutput(): void {
    $this->pluginManager->hasDefinition('calc')->willReturn(TRUE);
    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->getOutputSchema()->willReturn(['properties' => ['result' => []]]);
    $this->pluginManager->createInstance('calc')->willReturn($processor->reveal());

    $result = $this->validator()->validate(
      [$this->node('n1', 'calc')],
      [],
      [],
      [$this->entry('out', 'n1', 'nonexistent')],
    );

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_PORT_MISSING, 'schema.output.0');
  }

  /**
   * A missing plugin is reported once by R1, not again per exposed port.
   */
  public function testMissingPluginIsNotDoubleReported(): void {
    $this->pluginManager->hasDefinition('ghost')->willReturn(FALSE);
    $this->pluginManager->createInstance('ghost')->willThrow(new PluginNotFoundException('ghost'));

    $result = $this->validator()->validate(
      [$this->node('n1', 'ghost')],
      [],
      [$this->entry('foo', 'n1', 'result')],
      [],
    );

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_PLUGIN_MISSING, 'node.n1');
  }

  /**
   * A plugin is constructed at most once across both exposure sides.
   */
  public function testPluginInstanceIsMemoised(): void {
    $this->pluginManager->hasDefinition('calc')->willReturn(TRUE);
    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->getParameterSchema()->willReturn(['properties' => ['in' => []]]);
    $processor->getOutputSchema()->willReturn(['properties' => ['out' => []]]);
    $this->pluginManager->createInstance('calc')->willReturn($processor->reveal())->shouldBeCalledOnce();

    $result = $this->validator()->validate(
      [$this->node('n1', 'calc')],
      [],
      [$this->entry('a', 'n1', 'in'), $this->entry('b', 'n1', 'in')],
      [$this->entry('c', 'n1', 'out')],
    );

    $this->assertTrue($result->isValid());
  }

  // --- R7: edge exposure -----------------------------------------------------

  /**
   * Builds a node whose type resolves to metadata with the given ports.
   *
   * @param string $id
   *   The node id.
   * @param string $typeId
   *   The node type id (doubles as executor plugin id).
   * @param array<int, array<string, mixed>> $inputs
   *   Input port definitions (NodePort::fromArray shape).
   * @param array<int, array<string, mixed>> $outputs
   *   Output port definitions.
   * @param array<string, mixed> $config
   *   The node's data.config (carries any `ports` exposure overrides).
   *
   * @return array<string, mixed>
   *   A node array.
   */
  private function portNode(string $id, string $typeId, array $inputs, array $outputs, array $config = []): array {
    $this->pluginManager->hasDefinition($typeId)->willReturn(TRUE);
    // Terminal-reachability reads the source node's definition class; these
    // port nodes are ordinary (non-terminal) processors.
    $this->pluginManager->getDefinition($typeId, FALSE)
      ->willReturn(['class' => FlowDropNodeProcessorInterface::class]);
    $this->metadataResolver->resolveExecutorPlugin($typeId)->willReturn($typeId);
    $this->metadataResolver->buildForNodeTypeId($typeId)->willReturn(
      NodeMetadata::fromArray([
        'node_type_id' => $typeId,
        'executor_plugin' => $typeId,
        'inputs' => $inputs,
        'outputs' => $outputs,
      ])
    );
    return [
      'id' => $id,
      'data' => ['metadata' => ['node_type_id' => $typeId], 'config' => $config],
    ];
  }

  /**
   * An edge into a not-exposed (overridden) input port is rejected.
   */
  public function testEdgeIntoNotExposedInputPortIsRejected(): void {
    $target = $this->portNode('t', 'sink', [['id' => 'value']], [], [
      'ports' => ['inputs' => [['id' => 'value', 'exposed' => FALSE]]],
    ]);
    $source = $this->portNode('s', 'src', [], [['id' => 'result']]);

    $result = $this->validator()->validate(
      [$source, $target],
      [['source' => 's', 'sourceHandle' => 's-output-result', 'target' => 't', 'targetHandle' => 't-input-value']],
      [],
      [],
    );

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_EDGE_TARGET_NOT_EXPOSED, 'edge.0');
  }

  /**
   * An edge from a default-not-exposed output port (error) is rejected.
   */
  public function testEdgeFromNotExposedErrorPortIsRejected(): void {
    $source = $this->portNode('s', 'src', [], [
      ['id' => 'result'],
      ['id' => 'error', 'exposedByDefault' => FALSE],
    ]);
    $sink = $this->portNode('t', 'sink', [['id' => 'value']], []);

    $result = $this->validator()->validate(
      [$source, $sink],
      [['source' => 's', 'sourceHandle' => 's-output-error', 'target' => 't', 'targetHandle' => 't-input-value']],
      [],
      [],
    );

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_EDGE_SOURCE_NOT_EXPOSED, 'edge.0');
  }

  /**
   * Exposing the error port via config.ports makes its edge valid.
   */
  public function testEdgeFromExposedErrorPortIsValid(): void {
    $source = $this->portNode('s', 'src', [], [
      ['id' => 'result'],
      ['id' => 'error', 'exposedByDefault' => FALSE],
    ], ['ports' => ['outputs' => [['id' => 'error', 'exposed' => TRUE]]]]);
    $sink = $this->portNode('t', 'sink', [['id' => 'value']], []);

    $result = $this->validator()->validate(
      [$source, $sink],
      [['source' => 's', 'sourceHandle' => 's-output-error', 'target' => 't', 'targetHandle' => 't-input-value']],
      [],
      [],
    );

    $this->assertTrue($result->isValid());
  }

  /**
   * Exposed and dynamic/unknown port edges both pass.
   *
   * An edge to a default-exposed port is valid; a dynamic/unknown port is not
   * checked (no metadata default to enforce against).
   */
  public function testExposedAndUnknownPortEdgesAreValid(): void {
    $source = $this->portNode('s', 'src', [], [['id' => 'result']]);
    $target = $this->portNode('t', 'sink', [['id' => 'value']], []);

    $result = $this->validator()->validate(
      [$source, $target],
      [
        // Default-exposed input port: valid.
        ['source' => 's', 'sourceHandle' => 's-output-result', 'target' => 't', 'targetHandle' => 't-input-value'],
        // Dynamic port not declared in metadata: out of scope, not flagged.
        ['source' => 's', 'sourceHandle' => 's-output-result', 'target' => 't', 'targetHandle' => 't-input-first_name'],
      ],
      [],
      [],
    );

    $this->assertTrue($result->isValid());
  }

  // --- R8: edge endpoints exist ----------------------------------------------

  /**
   * An edge whose target node does not exist is rejected.
   */
  public function testR8DanglingTargetIsRejected(): void {
    $result = $this->validator()->validate(
      [$this->node('s')],
      [['source' => 's', 'target' => 'gone']],
      [],
      [],
    );

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_EDGE_TARGET_MISSING, 'edge.0');
  }

  /**
   * An edge whose source node does not exist is rejected.
   */
  public function testR8DanglingSourceIsRejected(): void {
    $result = $this->validator()->validate(
      [$this->node('t')],
      [['source' => 'gone', 'target' => 't']],
      [],
      [],
    );

    $this->assertSingleError($result, WorkflowValidatorInterface::CODE_EDGE_SOURCE_MISSING, 'edge.0');
  }

  /**
   * An edge missing both endpoints reports one error per side.
   */
  public function testR8BothEndpointsMissingYieldTwoErrors(): void {
    $result = $this->validator()->validate(
      [],
      [['source' => 'x', 'target' => 'y']],
      [],
      [],
    );

    $this->assertFalse($result->isValid());
    $codes = array_map(static fn ($e) => $e->getCode(), $result->getErrors());
    $this->assertEqualsCanonicalizing([
      WorkflowValidatorInterface::CODE_EDGE_SOURCE_MISSING,
      WorkflowValidatorInterface::CODE_EDGE_TARGET_MISSING,
    ], $codes);
  }

  /**
   * An edge whose endpoints both exist passes R8.
   */
  public function testR8ValidEdgePasses(): void {
    $result = $this->validator()->validate(
      [$this->node('s'), $this->node('t')],
      [['source' => 's', 'target' => 't']],
      [],
      [],
    );

    $this->assertTrue($result->isValid());
  }

}
