<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_workflow\Unit\Diagnostic;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop\DTO\NodeMetadata\NodeMetadataBuilder;
use Drupal\flowdrop\DTO\ValidationError;
use Drupal\flowdrop\DTO\ValidationResult;
use Drupal\flowdrop\DTO\ValidationWarning;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop_node_type\FlowDropNodeTypeInterface;
use Drupal\flowdrop_node_type\Service\NodeMetadataResolver;
use Drupal\flowdrop_workflow\Diagnostic\DiagnosticContext;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveEdge;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveNodeConfigKey;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\ReplaceNodeType;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\SetNodeConfig;
use Drupal\flowdrop_workflow\Diagnostic\Severity;
use Drupal\flowdrop_workflow\Diagnostic\ValidatorDiagnostic;
use Drupal\flowdrop_workflow\Service\WorkflowValidatorInterface;
use Drupal\flowdrop_workflow\WorkflowDefinitionInterface;
use Drupal\Tests\UnitTestCase;
use Prophecy\PhpUnit\ProphecyTrait;

/**
 * Tests the ValidatorDiagnostic adapter.
 *
 * Covers the validator-code → finding → remedy → mutation translation.
 *
 * @coversDefaultClass \Drupal\flowdrop_workflow\Diagnostic\ValidatorDiagnostic
 * @group flowdrop_workflow
 */
class ValidatorDiagnosticTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The mocked validator.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<WorkflowValidatorInterface>
   */
  private $validator;

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

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

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

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

    // Default: no other node types available (overridden per test).
    $this->nodeTypeStorage->loadMultiple()->willReturn([]);
  }

  /**
   * Builds the diagnostic under test.
   */
  private function diagnostic(): ValidatorDiagnostic {
    $etm = $this->prophesize(EntityTypeManagerInterface::class);
    $etm->getStorage('flowdrop_node_type')->willReturn($this->nodeTypeStorage->reveal());
    $diagnostic = new ValidatorDiagnostic(
      $this->validator->reveal(),
      $this->metadataResolver->reveal(),
      $etm->reveal(),
      $this->pluginManager->reveal(),
    );
    $diagnostic->setStringTranslation($this->getStringTranslationStub());
    return $diagnostic;
  }

  /**
   * Wraps nodes/edges in a DiagnosticContext, with the validator stubbed.
   *
   * @param array<int, array<string, mixed>> $nodes
   *   The workflow nodes.
   * @param array<int, array<string, mixed>> $edges
   *   The workflow edges.
   * @param \Drupal\flowdrop\DTO\ValidationResult $result
   *   The result the validator returns for this workflow.
   */
  private function context(array $nodes, array $edges, ValidationResult $result): DiagnosticContext {
    $workflow = $this->prophesize(WorkflowDefinitionInterface::class);
    $workflow->getNodes()->willReturn($nodes);
    $workflow->getEdges()->willReturn($edges);
    $workflow->getInputPorts()->willReturn([]);
    $workflow->getOutputPorts()->willReturn([]);
    $this->validator->validate($nodes, $edges, [], [])->willReturn($result);
    return new DiagnosticContext($workflow->reveal());
  }

  /**
   * A node array with a node-type anchor.
   *
   * @param string $id
   *   The node id.
   * @param array<string, mixed> $config
   *   The user config.
   * @param string $typeId
   *   The node-type anchor.
   *
   * @return array<string, mixed>
   *   The node.
   */
  private function node(string $id, array $config = [], string $typeId = 'calc'): array {
    return [
      'id' => $id,
      'data' => ['config' => $config, 'metadata' => ['node_type_id' => $typeId]],
    ];
  }

  /**
   * R8 (dangling edge) → a remove-edge finding keyed by the edge's id.
   */
  public function testEdgeMissingProducesRemoveEdge(): void {
    $edges = [['id' => 'e1', 'source' => 'ghost', 'target' => 'a']];
    $result = ValidationResult::failure([
      new ValidationError('edge.0', 'Edge references source node "ghost".', WorkflowValidatorInterface::CODE_EDGE_SOURCE_MISSING),
    ]);
    $context = $this->context([$this->node('a')], $edges, $result);

    $findings = $this->diagnostic()->diagnose($context);
    $this->assertCount(1, $findings);
    $finding = $findings[0];
    $this->assertSame(WorkflowValidatorInterface::CODE_EDGE_SOURCE_MISSING, $finding->code);
    // Stable locator: the edge's id, not its array index.
    $this->assertSame('edge.e1', $finding->locator);
    $this->assertSame(Severity::Error, $finding->severity);
    $this->assertNotNull($finding->remedy('remove_edge'));

    $mutations = $this->diagnostic()->buildMutations($finding, 'remove_edge', [], $context);
    $this->assertCount(1, $mutations);
    $this->assertInstanceOf(RemoveEdge::class, $mutations[0]);
    $this->assertSame('e1', $mutations[0]->edgeId);
  }

  /**
   * R7 (edge to a not-exposed port) → an expose-port mutation on the endpoint.
   */
  public function testEdgeExposureProducesExposePort(): void {
    $edges = [[
      'id' => 'e1',
      'source' => 'a',
      'target' => 'b',
      'targetHandle' => 'b-input-threshold',
    ],
    ];
    $result = ValidationResult::failure([
      new ValidationError('edge.0', 'Edge lands on a not-exposed input port.', WorkflowValidatorInterface::CODE_EDGE_TARGET_NOT_EXPOSED),
    ]);
    $context = $this->context([$this->node('a'), $this->node('b')], $edges, $result);

    $finding = $this->diagnostic()->diagnose($context)[0];
    $this->assertSame('edge.e1', $finding->locator);
    $this->assertNotNull($finding->remedy('expose_port'));
    $this->assertNotNull($finding->remedy('remove_edge'));

    $mutations = $this->diagnostic()->buildMutations($finding, 'expose_port', [], $context);
    $this->assertInstanceOf(SetNodeConfig::class, $mutations[0]);
    $this->assertSame('b', $mutations[0]->nodeId);
    $this->assertSame('ports', $mutations[0]->key);
    $this->assertSame(
      ['inputs' => [['id' => 'threshold', 'exposed' => TRUE]], 'outputs' => []],
      $mutations[0]->value,
    );
  }

  /**
   * R6 invalid config → reset-to-default sets the node-type's default value.
   */
  public function testConfigInvalidResetsToDefault(): void {
    $result = ValidationResult::failure([
      new ValidationError('node.a.config.threshold', 'Config "threshold" must be of type "integer".', WorkflowValidatorInterface::CODE_CONFIG_INVALID),
    ]);
    $context = $this->context([$this->node('a', ['threshold' => 'oops'])], [], $result);
    $this->metadataResolver->buildForNodeTypeId('calc')
      ->willReturn(NodeMetadataBuilder::create()->setConfig(['threshold' => 5])->build());

    $finding = $this->diagnostic()->diagnose($context)[0];
    $this->assertSame(WorkflowValidatorInterface::CODE_CONFIG_INVALID, $finding->code);
    $this->assertNotNull($finding->remedy('reset_config'));
    $this->assertNotNull($finding->remedy('remove_node'));

    $mutations = $this->diagnostic()->buildMutations($finding, 'reset_config', [], $context);
    $this->assertInstanceOf(SetNodeConfig::class, $mutations[0]);
    $this->assertSame('threshold', $mutations[0]->key);
    $this->assertSame(5, $mutations[0]->value);
  }

  /**
   * R6 invalid config with no declared default → reset removes the bad key.
   */
  public function testConfigInvalidWithoutDefaultRemovesKey(): void {
    $result = ValidationResult::failure([
      new ValidationError('node.a.config.threshold', 'Config "threshold" must be of type "integer".', WorkflowValidatorInterface::CODE_CONFIG_INVALID),
    ]);
    $context = $this->context([$this->node('a', ['threshold' => 'oops'])], [], $result);
    $this->metadataResolver->buildForNodeTypeId('calc')
      ->willReturn(NodeMetadataBuilder::create()->setConfig([])->build());

    $finding = $this->diagnostic()->diagnose($context)[0];
    $mutations = $this->diagnostic()->buildMutations($finding, 'reset_config', [], $context);
    $this->assertInstanceOf(RemoveNodeConfigKey::class, $mutations[0]);
    $this->assertSame('threshold', $mutations[0]->key);
  }

  /**
   * R1 (missing plugin) offers a guarded replace-node-type remedy.
   *
   * It lists only installed types, and the apply path rejects a type id that
   * was never offered.
   */
  public function testPluginMissingReplaceNodeTypeIsGuarded(): void {
    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->id()->willReturn('other');
    $nodeType->label()->willReturn('Other');
    $nodeType->getExecutorPlugin()->willReturn('flowdrop:other');
    $this->nodeTypeStorage->loadMultiple()->willReturn([$nodeType->reveal()]);
    $this->pluginManager->hasDefinition('flowdrop:other')->willReturn(TRUE);

    $result = ValidationResult::failure([
      new ValidationError('node.a', 'Node a references a missing processor.', WorkflowValidatorInterface::CODE_PLUGIN_MISSING),
    ]);
    $context = $this->context([$this->node('a', [], 'broken')], [], $result);

    $finding = $this->diagnostic()->diagnose($context)[0];
    $replace = $finding->remedy('replace_node_type');
    $this->assertNotNull($replace);
    $this->assertSame(['other' => 'Other'], $replace->params['choices']);

    // A valid choice builds the mutation.
    $ok = $this->diagnostic()->buildMutations($finding, 'replace_node_type', ['node_type_id' => 'other'], $context);
    $this->assertInstanceOf(ReplaceNodeType::class, $ok[0]);
    $this->assertSame('other', $ok[0]->newNodeTypeId);

    // A type id that was never offered is rejected server-side.
    $evil = $this->diagnostic()->buildMutations($finding, 'replace_node_type', ['node_type_id' => 'evil'], $context);
    $this->assertSame([], $evil);
  }

  /**
   * An unknown-config-key warning becomes a removable warning finding.
   */
  public function testUnknownConfigKeyWarning(): void {
    $result = ValidationResult::success([
      new ValidationWarning('node.a.config.legacy', 'Config key "legacy" is not declared.'),
    ]);
    $context = $this->context([$this->node('a', ['legacy' => 1])], [], $result);

    $findings = $this->diagnostic()->diagnose($context);
    $this->assertCount(1, $findings);
    $this->assertSame(Severity::Warning, $findings[0]->severity);

    $mutations = $this->diagnostic()->buildMutations($findings[0], 'remove_config_key', [], $context);
    $this->assertInstanceOf(RemoveNodeConfigKey::class, $mutations[0]);
    $this->assertSame('legacy', $mutations[0]->key);
  }

}
