<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_runtime\Unit\Service\Runtime;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop\DTO\ParameterBagInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\CancellableNodeProcessorInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\FlowDropNodeProcessorInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop_job\FlowDropJobInterface;
use Drupal\flowdrop_node_type\FlowDropNodeTypeInterface;
use Drupal\flowdrop_orchestration\DTO\ResolvedInterruptInterface;
use Drupal\flowdrop_runtime\Service\Runtime\InterruptCancellationDispatcher;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Log\LoggerInterface;

/**
 * Tests InterruptCancellationDispatcher's processor lookup + invocation.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\Runtime\InterruptCancellationDispatcher
 * @group flowdrop_runtime
 */
class InterruptCancellationDispatcherTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * Mocked entity type manager.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private ObjectProphecy $entityTypeManager;

  /**
   * Mocked job storage.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private ObjectProphecy $jobStorage;

  /**
   * Mocked node type storage.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private ObjectProphecy $nodeTypeStorage;

  /**
   * Mocked node processor plugin manager.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private ObjectProphecy $nodeProcessorManager;

  /**
   * Mocked logger.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private ObjectProphecy $logger;

  /**
   * The dispatcher under test.
   */
  private InterruptCancellationDispatcher $dispatcher;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
    $this->jobStorage = $this->prophesize(EntityStorageInterface::class);
    $this->nodeTypeStorage = $this->prophesize(EntityStorageInterface::class);
    $this->entityTypeManager->getStorage('flowdrop_job')->willReturn($this->jobStorage->reveal());
    $this->entityTypeManager->getStorage('flowdrop_node_type')->willReturn($this->nodeTypeStorage->reveal());
    $this->nodeProcessorManager = $this->prophesize(FlowDropNodeProcessorPluginManager::class);
    $this->logger = $this->prophesize(LoggerInterface::class);

    $this->dispatcher = new InterruptCancellationDispatcher(
      $this->entityTypeManager->reveal(),
      $this->nodeProcessorManager->reveal(),
      $this->logger->reveal(),
    );
  }

  /**
   * NULL job ID: no-op.
   */
  public function testNullJobIdIsNoOp(): void {
    $interrupt = $this->prophesize(ResolvedInterruptInterface::class);
    $this->jobStorage->load(Argument::any())->shouldNotBeCalled();

    $this->dispatcher->invokeCancelHook(NULL, $interrupt->reveal());
  }

  /**
   * Missing job: no-op.
   */
  public function testMissingJobIsNoOp(): void {
    $this->jobStorage->load(99)->willReturn(NULL);
    $this->nodeTypeStorage->load(Argument::any())->shouldNotBeCalled();

    $interrupt = $this->prophesize(ResolvedInterruptInterface::class);
    $this->dispatcher->invokeCancelHook(99, $interrupt->reveal());
  }

  /**
   * Non-Cancellable processor: not invoked, but no error.
   */
  public function testNonCancellableProcessorSkipped(): void {
    $job = $this->buildJobMock(99, 'my_node_type', ['x' => 1]);
    $this->jobStorage->load(99)->willReturn($job->reveal());

    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn('boring_plugin');
    $this->nodeTypeStorage->load('my_node_type')->willReturn($nodeType->reveal());

    // Plain processor — no Cancellable interface. cancel() must not be
    // invoked (would error: method doesn't exist).
    $plainProcessor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $this->nodeProcessorManager->createInstance('boring_plugin')->willReturn($plainProcessor->reveal());

    $interrupt = $this->prophesize(ResolvedInterruptInterface::class);
    $this->dispatcher->invokeCancelHook(99, $interrupt->reveal());

    // Reaching here without exception is the assertion — no Cancellable
    // method was called and no warning was logged.
    $this->logger->warning(Argument::any(), Argument::any())->shouldNotHaveBeenCalled();
    $this->addToAssertionCount(1);
  }

  /**
   * Cancellable processor: cancel() invoked with the interrupt + params.
   */
  public function testCancellableProcessorInvoked(): void {
    $job = $this->buildJobMock(99, 'my_node_type', ['foo' => 'bar']);
    $this->jobStorage->load(99)->willReturn($job->reveal());

    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn('cancellable_plugin');
    $this->nodeTypeStorage->load('my_node_type')->willReturn($nodeType->reveal());

    $processor = $this->prophesize(TestCancellableProcessor::class);
    $processor->cancel(
      Argument::type(ResolvedInterruptInterface::class),
      Argument::that(function (ParameterBagInterface $bag) {
        // The runtime built the bag from job.getInputData().
        return $bag->get('foo') === 'bar';
      }),
    )->shouldBeCalled();
    $this->nodeProcessorManager->createInstance('cancellable_plugin')->willReturn($processor->reveal());

    $interrupt = $this->prophesize(ResolvedInterruptInterface::class);
    $interrupt->getInterruptId()->willReturn('uuid-x');
    $this->dispatcher->invokeCancelHook(99, $interrupt->reveal());
  }

  /**
   * Processor throws: error logged, no rethrow.
   */
  public function testProcessorExceptionIsLogged(): void {
    $job = $this->buildJobMock(99, 'my_node_type', []);
    $this->jobStorage->load(99)->willReturn($job->reveal());

    $nodeType = $this->prophesize(FlowDropNodeTypeInterface::class);
    $nodeType->getExecutorPlugin()->willReturn('throwy_plugin');
    $this->nodeTypeStorage->load('my_node_type')->willReturn($nodeType->reveal());

    $processor = $this->prophesize(TestCancellableProcessor::class);
    $processor->cancel(Argument::cetera())
      ->willThrow(new \RuntimeException('slack api down'));
    $this->nodeProcessorManager->createInstance('throwy_plugin')->willReturn($processor->reveal());

    $this->logger->warning(Argument::any(), Argument::type('array'))->shouldBeCalled();

    $interrupt = $this->prophesize(ResolvedInterruptInterface::class);
    $interrupt->getInterruptId()->willReturn('uuid-z');
    // No exception escapes — dispatcher swallows for best-effort teardown.
    $this->dispatcher->invokeCancelHook(99, $interrupt->reveal());
  }

  /**
   * Builds a job mock with the standard metadata layout.
   */
  private function buildJobMock(int $id, string $nodeTypeId, array $inputData): ObjectProphecy {
    $job = $this->prophesize(FlowDropJobInterface::class);
    $job->id()->willReturn($id);
    $job->getMetadataValue('node_type_id', NULL)->willReturn($nodeTypeId);
    $job->getInputData()->willReturn($inputData);
    return $job;
  }

}

/**
 * Test double class that implements both processor interfaces.
 *
 * Prophecy can't directly mock a union of two interfaces; this concrete
 * class is mocked instead.
 */
abstract class TestCancellableProcessor implements
  FlowDropNodeProcessorInterface,
  CancellableNodeProcessorInterface {}
