<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_workflow\Unit\Service;

use Drupal\flowdrop_workflow\FlowDropWorkflowInterface;
use Drupal\flowdrop_workflow\Service\WorkflowSchemaBuilderInterface;
use Drupal\flowdrop_workflow\Service\WorkflowSchemaDifferInterface;
use Drupal\flowdrop_workflow\Service\WorkflowSchemaSnapshotter;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;

/**
 * Tests the WorkflowSchemaSnapshotter service.
 *
 * The snapshotter owns the rebuild + semver bump pipeline that used to live
 * inside FlowDropWorkflow::preSave(). These tests cover the rules that the
 * review flagged as untested: the "0.0.0 → 1.0.0 on first declaration" edge
 * case, standard major/minor bumps, the isSyncing() short-circuit, and the
 * exposure-map deep-equality short-circuit.
 *
 * @coversDefaultClass \Drupal\flowdrop_workflow\Service\WorkflowSchemaSnapshotter
 * @group flowdrop_workflow
 */
class WorkflowSchemaSnapshotterTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The mocked schema builder.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<WorkflowSchemaBuilderInterface>
   */
  private $builder;

  /**
   * The mocked schema differ.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<WorkflowSchemaDifferInterface>
   */
  private $differ;

  /**
   * The mocked logger.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<LoggerInterface>
   */
  private $logger;

  /**
   * The snapshotter under test.
   */
  private WorkflowSchemaSnapshotter $snapshotter;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->builder = $this->prophesize(WorkflowSchemaBuilderInterface::class);
    $this->differ = $this->prophesize(WorkflowSchemaDifferInterface::class);
    $this->logger = $this->prophesize(LoggerInterface::class);
    $this->snapshotter = new WorkflowSchemaSnapshotter(
      $this->builder->reveal(),
      $this->differ->reveal(),
      $this->logger->reveal(),
    );
  }

  /**
   * IsSyncing() short-circuits the entire pipeline — no service calls.
   */
  public function testSyncingSkipsRebuild(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->isSyncing()->willReturn(TRUE);
    $workflow->getInputPorts()->shouldNotBeCalled();

    $this->builder->build(Argument::cetera())->shouldNotBeCalled();
    $this->differ->diff(Argument::cetera())->shouldNotBeCalled();

    $this->snapshotter->rebuildIfNeeded($workflow->reveal(), NULL, FALSE);
  }

  /**
   * Unchanged exposure maps short-circuit before invoking the builder.
   */
  public function testUnchangedPortsSkipsRebuild(): void {
    $ports = [['name' => 'in', 'node_id' => 'n1', 'port' => 'p1']];

    $original = $this->prophesize(FlowDropWorkflowInterface::class);
    $original->getInputPorts()->willReturn($ports);
    $original->getOutputPorts()->willReturn([]);

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->isSyncing()->willReturn(FALSE);
    $workflow->getInputPorts()->willReturn($ports);
    $workflow->getOutputPorts()->willReturn([]);

    $this->builder->build(Argument::cetera())->shouldNotBeCalled();
    $this->differ->diff(Argument::cetera())->shouldNotBeCalled();
    $workflow->setParameterSchema(Argument::any())->shouldNotBeCalled();

    $this->snapshotter->rebuildIfNeeded(
      $workflow->reveal(),
      $original->reveal(),
      FALSE,
    );
  }

  /**
   * Forced rebuild bypasses the equality check.
   */
  public function testForcedRebuildBypassesEquality(): void {
    $ports = [['name' => 'in', 'node_id' => 'n1', 'port' => 'p1']];
    $newSchema = ['type' => 'object', 'properties' => ['in' => ['type' => 'string']]];

    $original = $this->prophesize(FlowDropWorkflowInterface::class);
    $original->getInputPorts()->willReturn($ports);
    $original->getOutputPorts()->willReturn([]);
    $original->getParameterSchema()->willReturn(NULL);
    $original->getOutputSchema()->willReturn(NULL);
    $original->getSchemaVersion()->willReturn('0.0.0');

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->isSyncing()->willReturn(FALSE);
    $workflow->getInputPorts()->willReturn($ports);
    $workflow->getOutputPorts()->willReturn([]);
    $workflow->id()->willReturn('test_workflow');

    $this->builder->build($workflow, $ports, 'input')->willReturn($newSchema);
    $this->differ->diff(NULL, $newSchema, NULL, NULL)
      ->willReturn(['level' => 'major', 'changes' => []]);

    $workflow->setParameterSchema($newSchema)->shouldBeCalled()->willReturn($workflow->reveal());
    $workflow->setOutputSchema(NULL)->shouldBeCalled()->willReturn($workflow->reveal());
    $workflow->setSchemaVersion('1.0.0')->shouldBeCalled()->willReturn($workflow->reveal());

    $this->snapshotter->rebuildIfNeeded(
      $workflow->reveal(),
      $original->reveal(),
      TRUE,
    );
  }

  /**
   * Tests 0.0.0 + major bumps to 1.0.0 on first declaration.
   */
  public function testInitialMajorBumpsToOneZeroZero(): void {
    $this->expectBump('0.0.0', 'major', '1.0.0');
  }

  /**
   * Tests 0.0.0 + minor bumps to 1.0.0 on first declaration.
   */
  public function testInitialMinorBumpsToOneZeroZero(): void {
    $this->expectBump('0.0.0', 'minor', '1.0.0');
  }

  /**
   * Tests 1.0.0 + major bumps to 2.0.0.
   */
  public function testStandardMajorBump(): void {
    $this->expectBump('1.0.0', 'major', '2.0.0');
  }

  /**
   * Tests 1.2.3 + major bumps to 2.0.0 (minor + patch reset).
   */
  public function testMajorBumpResetsMinorAndPatch(): void {
    $this->expectBump('1.2.3', 'major', '2.0.0');
  }

  /**
   * Tests 1.2.0 + minor bumps to 1.3.0.
   */
  public function testStandardMinorBump(): void {
    $this->expectBump('1.2.0', 'minor', '1.3.0');
  }

  /**
   * Tests 1.2.3 + minor bumps to 1.3.0 (patch reset).
   */
  public function testMinorBumpResetsPatch(): void {
    $this->expectBump('1.2.3', 'minor', '1.3.0');
  }

  /**
   * Tests that level "none" keeps the version unchanged.
   *
   * The setSchemaVersion method must NOT be called in this case.
   */
  public function testNoneLevelKeepsVersion(): void {
    $ports = [['name' => 'in', 'node_id' => 'n', 'port' => 'p']];
    $changedPorts = [['name' => 'in', 'node_id' => 'n', 'port' => 'p2']];

    $original = $this->prophesize(FlowDropWorkflowInterface::class);
    $original->getInputPorts()->willReturn($ports);
    $original->getOutputPorts()->willReturn([]);
    $original->getParameterSchema()->willReturn(['type' => 'object']);
    $original->getOutputSchema()->willReturn(NULL);
    $original->getSchemaVersion()->willReturn('2.5.1');

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->isSyncing()->willReturn(FALSE);
    $workflow->getInputPorts()->willReturn($changedPorts);
    $workflow->getOutputPorts()->willReturn([]);
    $workflow->id()->willReturn('test_workflow');

    $this->builder->build($workflow, $changedPorts, 'input')->willReturn(['type' => 'object']);
    $this->differ->diff(['type' => 'object'], ['type' => 'object'], NULL, NULL)
      ->willReturn(['level' => 'none', 'changes' => []]);

    $workflow->setParameterSchema(['type' => 'object'])->willReturn($workflow->reveal());
    $workflow->setOutputSchema(NULL)->willReturn($workflow->reveal());
    // Crucial: setSchemaVersion is NOT called when the level is none.
    $workflow->setSchemaVersion(Argument::any())->shouldNotBeCalled();

    $this->snapshotter->rebuildIfNeeded($workflow->reveal(), $original->reveal(), FALSE);
  }

  /**
   * Builder throwing preserves the previous snapshot — no exception escapes.
   */
  public function testBuilderFailurePreservesSnapshot(): void {
    $ports = [['name' => 'in', 'node_id' => 'n', 'port' => 'p']];

    $original = $this->prophesize(FlowDropWorkflowInterface::class);
    $original->getInputPorts()->willReturn([]);
    $original->getOutputPorts()->willReturn([]);
    $original->getParameterSchema()->willReturn(NULL);
    $original->getOutputSchema()->willReturn(NULL);
    $original->getSchemaVersion()->willReturn('1.0.0');

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->isSyncing()->willReturn(FALSE);
    $workflow->getInputPorts()->willReturn($ports);
    $workflow->getOutputPorts()->willReturn([]);
    $workflow->id()->willReturn('failing_workflow');

    $this->builder->build($workflow, $ports, 'input')
      ->willThrow(new \RuntimeException('plugin missing'));
    $this->differ->diff(Argument::cetera())->shouldNotBeCalled();
    $workflow->setParameterSchema(Argument::any())->shouldNotBeCalled();
    $this->logger->error(Argument::containingString('Schema rebuild failed'), Argument::any())
      ->shouldBeCalled();

    $this->snapshotter->rebuildIfNeeded($workflow->reveal(), $original->reveal(), FALSE);
  }

  /**
   * Drives one full bump cycle and asserts the resulting setSchemaVersion call.
   */
  private function expectBump(string $previous, string $level, string $expected): void {
    $ports = [['name' => 'in', 'node_id' => 'n', 'port' => 'p']];
    $newSchema = ['type' => 'object', 'properties' => ['in' => ['type' => 'string']]];

    $original = $this->prophesize(FlowDropWorkflowInterface::class);
    $original->getInputPorts()->willReturn([]);
    $original->getOutputPorts()->willReturn([]);
    $original->getParameterSchema()->willReturn(NULL);
    $original->getOutputSchema()->willReturn(NULL);
    $original->getSchemaVersion()->willReturn($previous);

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->isSyncing()->willReturn(FALSE);
    $workflow->getInputPorts()->willReturn($ports);
    $workflow->getOutputPorts()->willReturn([]);
    $workflow->id()->willReturn('test_workflow');

    $this->builder->build($workflow, $ports, 'input')->willReturn($newSchema);
    $this->differ->diff(NULL, $newSchema, NULL, NULL)
      ->willReturn(['level' => $level, 'changes' => []]);

    $workflow->setParameterSchema($newSchema)->willReturn($workflow->reveal());
    $workflow->setOutputSchema(NULL)->willReturn($workflow->reveal());
    $workflow->setSchemaVersion($expected)->shouldBeCalled()->willReturn($workflow->reveal());

    $this->snapshotter->rebuildIfNeeded($workflow->reveal(), $original->reveal(), FALSE);
  }

}
