<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_workflow\Unit\Service;

use Drupal\flowdrop_workflow\Diagnostic\Mutation\WorkflowMutationInterface;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveEdge;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveNode;
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\Service\WorkflowMutator;
use Drupal\flowdrop_workflow\WorkflowDefinitionInterface;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;

/**
 * Tests the WorkflowMutator — the single applier of mutation primitives.
 *
 * Covers each primitive, the node-removal → edge cascade, and the precedence
 * rule that a node removal supersedes config edits on the same node.
 *
 * @coversDefaultClass \Drupal\flowdrop_workflow\Service\WorkflowMutator
 * @group flowdrop_workflow
 */
class WorkflowMutatorTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The captured node list passed to setNodes().
   *
   * @var array<int, mixed>|null
   */
  private ?array $capturedNodes = NULL;

  /**
   * The captured edge list passed to setEdges().
   *
   * @var array<int, mixed>|null
   */
  private ?array $capturedEdges = NULL;

  /**
   * Applies the mutations to a workflow seeded with the given nodes/edges.
   *
   * @param array<int, array<string, mixed>> $nodes
   *   The seed nodes.
   * @param array<int, array<string, mixed>> $edges
   *   The seed edges.
   * @param list<\Drupal\flowdrop_workflow\Diagnostic\Mutation\WorkflowMutationInterface> $mutations
   *   The mutations to apply.
   */
  private function apply(array $nodes, array $edges, array $mutations): void {
    $workflow = $this->prophesize(WorkflowDefinitionInterface::class);
    $workflow->getNodes()->willReturn($nodes);
    $workflow->getEdges()->willReturn($edges);

    // The setters return $this (typed `static`), so the capture closures must
    // return the revealed double. $double is filled by reference before apply()
    // triggers them.
    $capturedNodes = NULL;
    $capturedEdges = NULL;
    $double = NULL;
    $workflow->setNodes(Argument::any())->will(function ($args) use (&$capturedNodes, &$double) {
      $capturedNodes = $args[0];
      return $double;
    });
    $workflow->setEdges(Argument::any())->will(function ($args) use (&$capturedEdges, &$double) {
      $capturedEdges = $args[0];
      return $double;
    });

    $double = $workflow->reveal();
    (new WorkflowMutator())->apply($double, $mutations);

    $this->capturedNodes = $capturedNodes;
    $this->capturedEdges = $capturedEdges;
  }

  /**
   * A minimal node array.
   *
   * @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],
      ],
    ];
  }

  /**
   * A minimal edge array.
   *
   * @param string $id
   *   The edge id.
   * @param string $source
   *   The source node id.
   * @param string $target
   *   The target node id.
   *
   * @return array<string, mixed>
   *   The edge.
   */
  private function edge(string $id, string $source, string $target): array {
    return ['id' => $id, 'source' => $source, 'target' => $target];
  }

  /**
   * Returns the captured node ids in order.
   *
   * @return list<string>
   *   The ids.
   */
  private function nodeIds(): array {
    return array_map(static fn (array $n): string => (string) $n['id'], $this->capturedNodes ?? []);
  }

  /**
   * Returns the captured edge ids in order.
   *
   * @return list<string>
   *   The ids.
   */
  private function edgeIds(): array {
    return array_map(static fn (array $e): string => (string) $e['id'], $this->capturedEdges ?? []);
  }

  /**
   * RemoveEdge drops only the named edge.
   */
  public function testRemoveEdge(): void {
    $this->apply(
      [$this->node('a'), $this->node('b')],
      [$this->edge('e1', 'a', 'b'), $this->edge('e2', 'a', 'b')],
      [new RemoveEdge('e1')],
    );
    $this->assertSame(['a', 'b'], $this->nodeIds());
    $this->assertSame(['e2'], $this->edgeIds());
  }

  /**
   * RemoveNode drops the node and cascades to its connected edges.
   */
  public function testRemoveNodeCascadesEdges(): void {
    $this->apply(
      [$this->node('a'), $this->node('b'), $this->node('c')],
      [$this->edge('e1', 'a', 'b'), $this->edge('e2', 'b', 'c'), $this->edge('e3', 'a', 'c')],
      [new RemoveNode('b')],
    );
    $this->assertSame(['a', 'c'], $this->nodeIds());
    // e1 (a→b) and e2 (b→c) both touch b; only e3 (a→c) survives.
    $this->assertSame(['e3'], $this->edgeIds());
  }

  /**
   * SetNodeConfig sets a single key without disturbing others.
   */
  public function testSetNodeConfig(): void {
    $this->apply(
      [$this->node('a', ['keep' => 1])],
      [],
      [new SetNodeConfig('a', 'added', 'v', 'set added')],
    );
    $this->assertSame(['keep' => 1, 'added' => 'v'], $this->capturedNodes[0]['data']['config']);
  }

  /**
   * RemoveNodeConfigKey deletes only the named key.
   */
  public function testRemoveNodeConfigKey(): void {
    $this->apply(
      [$this->node('a', ['drop' => 1, 'keep' => 2])],
      [],
      [new RemoveNodeConfigKey('a', 'drop')],
    );
    $this->assertSame(['keep' => 2], $this->capturedNodes[0]['data']['config']);
  }

  /**
   * ReplaceNodeType re-anchors only the node_type_id.
   */
  public function testReplaceNodeType(): void {
    $this->apply(
      [$this->node('a', ['k' => 1], 'old_type')],
      [],
      [new ReplaceNodeType('a', 'new_type')],
    );
    $this->assertSame('new_type', $this->capturedNodes[0]['data']['metadata']['node_type_id']);
    // Config untouched.
    $this->assertSame(['k' => 1], $this->capturedNodes[0]['data']['config']);
  }

  /**
   * A node removal supersedes a config edit queued on the same node.
   */
  public function testRemoveNodeSupersedesConfigEdit(): void {
    $this->apply(
      [$this->node('a', ['k' => 1]), $this->node('b')],
      [],
      [new SetNodeConfig('a', 'k', 99, 'set k'), new RemoveNode('a')],
    );
    $this->assertSame(['b'], $this->nodeIds());
  }

  /**
   * An id-less edge can be removed by its numeric index fallback.
   */
  public function testRemoveIdlessEdgeByIndex(): void {
    $this->apply(
      [$this->node('a'), $this->node('b')],
      // The second edge has no id — the diagnostic falls back to its index.
      [$this->edge('e1', 'a', 'b'), ['source' => 'a', 'target' => 'b']],
      [new RemoveEdge('1')],
    );
    $this->assertSame(['e1'], $this->edgeIds());
  }

  /**
   * An unknown mutation type is rejected — the vocabulary stays closed.
   */
  public function testUnknownMutationThrows(): void {
    $bogus = new class implements WorkflowMutationInterface {

      /**
       * {@inheritdoc}
       */
      public function summary(): string {
        return 'bogus';
      }

    };
    $this->expectException(\InvalidArgumentException::class);
    $this->apply([$this->node('a')], [], [$bogus]);
  }

}
