<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_interrupt\Unit\EventSubscriber;

use Drupal\flowdrop_interrupt\Entity\FlowDropInterruptInterface;
use Drupal\flowdrop_interrupt\Enum\InterruptDirection;
use Drupal\flowdrop_interrupt\Enum\InterruptStatus;
use Drupal\flowdrop_interrupt\Enum\InterruptType;
use Drupal\flowdrop_interrupt\EventSubscriber\PipelineSignalPollSubscriber;
use Drupal\flowdrop_interrupt\Service\InterruptManagerInterface;
use Drupal\flowdrop_runtime\Event\PipelineSignalPollEvent;
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 PipelineSignalPollSubscriber's response to inward signal lookups.
 *
 * @coversDefaultClass \Drupal\flowdrop_interrupt\EventSubscriber\PipelineSignalPollSubscriber
 * @group flowdrop_interrupt
 */
class PipelineSignalPollSubscriberTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * Mocked interrupt manager.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  private ObjectProphecy $interruptManager;

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

  /**
   * Subscriber under test.
   */
  private PipelineSignalPollSubscriber $subscriber;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->interruptManager = $this->prophesize(InterruptManagerInterface::class);
    $this->logger = $this->prophesize(LoggerInterface::class);
    $this->subscriber = new PipelineSignalPollSubscriber(
      $this->interruptManager->reveal(),
      $this->logger->reveal(),
    );
  }

  /**
   * No pending signal: the event is left untouched.
   */
  public function testNoPendingSignal(): void {
    $this->interruptManager->findPendingInwardSignal('pipe_1')->willReturn(NULL);

    $event = new PipelineSignalPollEvent(
      pipelineId: 'pipe_1',
      executionId: 'exec_1',
      workflowId: 'wf_1',
    );

    $this->subscriber->onPoll($event);
    $this->assertNull($event->getSignal());
  }

  /**
   * Cancel signal: outward interrupts cancelled, signal acknowledged.
   *
   * Phase 4: each outward interrupt is iterated, the (optional) cancel()
   * hook is invoked, then the entity is marked cancelled. Event attaches
   * the Cancel action with the signal's UUID/reason/uid.
   */
  public function testCancelSignal(): void {
    $signal = $this->prophesize(FlowDropInterruptInterface::class);
    $signal->getType()->willReturn(InterruptType::Cancel);
    $signal->uuid()->willReturn('uuid-cancel');
    $signal->getReason()->willReturn('Operator stop');
    $signal->getInitiatorUid()->willReturn(42);
    $signal->acknowledge()->shouldBeCalled()->willReturn($signal->reveal());
    $signal->save()->shouldBeCalled();

    // Two pending outward interrupts on the pipeline.
    $outwards = [];
    for ($i = 0; $i < 2; $i++) {
      $o = $this->prophesize(FlowDropInterruptInterface::class);
      $o->getDirection()->willReturn(InterruptDirection::Outward);
      $o->getStatus()->willReturn(InterruptStatus::Pending);
      $o->getJobId()->willReturn(NULL);
      $o->cancel()->shouldBeCalled()->willReturn($o->reveal());
      $o->save()->shouldBeCalled();
      $outwards[] = $o->reveal();
    }
    unset($i);

    $this->interruptManager->findPendingInwardSignal('pipe_1')->willReturn($signal->reveal());
    $this->interruptManager->getPendingInterruptsForPipeline('pipe_1')->willReturn($outwards);

    $event = new PipelineSignalPollEvent(
      pipelineId: 'pipe_1',
      executionId: 'exec_1',
      workflowId: 'wf_1',
    );

    $this->subscriber->onPoll($event);

    $payload = $event->getSignal();
    $this->assertNotNull($payload);
    $this->assertSame(PipelineSignalPollEvent::ACTION_CANCEL, $payload['action']);
    $this->assertSame('uuid-cancel', $payload['signal_id']);
    $this->assertSame('Operator stop', $payload['reason']);
    $this->assertSame(42, $payload['initiator_uid']);
  }

  /**
   * Pause signal: NOT acknowledged here — entity stays pending.
   *
   * The inbox / Resume action resolves it later. Event attaches the
   * Pause action so the orchestrator marks the pipeline paused.
   */
  public function testPauseSignal(): void {
    $signal = $this->prophesize(FlowDropInterruptInterface::class);
    $signal->getType()->willReturn(InterruptType::Pause);
    $signal->uuid()->willReturn('uuid-pause');
    $signal->getReason()->willReturn('Maintenance');
    $signal->getInitiatorUid()->willReturn(7);
    // Pause signal MUST NOT be acknowledged or cancelled here.
    $signal->acknowledge()->shouldNotBeCalled();
    $signal->save()->shouldNotBeCalled();

    $this->interruptManager->findPendingInwardSignal('pipe_2')->willReturn($signal->reveal());
    $this->interruptManager->getPendingInterruptsForPipeline(Argument::any())->shouldNotBeCalled();

    $event = new PipelineSignalPollEvent(
      pipelineId: 'pipe_2',
      executionId: 'exec_2',
      workflowId: 'wf_2',
    );

    $this->subscriber->onPoll($event);

    $payload = $event->getSignal();
    $this->assertNotNull($payload);
    $this->assertSame(PipelineSignalPollEvent::ACTION_PAUSE, $payload['action']);
    $this->assertSame('uuid-pause', $payload['signal_id']);
  }

  /**
   * Phase 4: per-outward cancel() hook invoked before entity cancellation.
   *
   * Each pending outward interrupt's owning node gets a chance to release
   * external resources via the dispatcher BEFORE the entity flips to
   * cancelled.
   */
  public function testCancelInvokesPerInterruptCancelHook(): void {
    $dispatcher = $this->prophesize(InterruptCancellationDispatcher::class);
    $subscriber = new PipelineSignalPollSubscriber(
      $this->interruptManager->reveal(),
      $this->logger->reveal(),
      $dispatcher->reveal(),
    );

    $signal = $this->prophesize(FlowDropInterruptInterface::class);
    $signal->getType()->willReturn(InterruptType::Cancel);
    $signal->uuid()->willReturn('uuid-cancel');
    $signal->getReason()->willReturn(NULL);
    $signal->getInitiatorUid()->willReturn(NULL);
    $signal->acknowledge()->willReturn($signal->reveal());
    $signal->save()->shouldBeCalled();

    // Two outward interrupts with distinct job IDs.
    $o1 = $this->prophesize(FlowDropInterruptInterface::class);
    $o1->getDirection()->willReturn(InterruptDirection::Outward);
    $o1->getStatus()->willReturn(InterruptStatus::Pending);
    $o1->getJobId()->willReturn(11);
    $o1->cancel()->willReturn($o1->reveal());
    $o1->save()->shouldBeCalled();

    $o2 = $this->prophesize(FlowDropInterruptInterface::class);
    $o2->getDirection()->willReturn(InterruptDirection::Outward);
    $o2->getStatus()->willReturn(InterruptStatus::Pending);
    $o2->getJobId()->willReturn(22);
    $o2->cancel()->willReturn($o2->reveal());
    $o2->save()->shouldBeCalled();

    // Dispatcher invoked once per interrupt with the right job ID.
    $dispatcher->invokeCancelHook(11, $o1->reveal())->shouldBeCalled();
    $dispatcher->invokeCancelHook(22, $o2->reveal())->shouldBeCalled();

    $this->interruptManager->findPendingInwardSignal('pipe_4')->willReturn($signal->reveal());
    $this->interruptManager->getPendingInterruptsForPipeline('pipe_4')->willReturn([
      $o1->reveal(),
      $o2->reveal(),
    ]);

    $event = new PipelineSignalPollEvent(
      pipelineId: 'pipe_4',
      executionId: 'exec_4',
      workflowId: 'wf_4',
    );

    $subscriber->onPoll($event);

    $this->assertNotNull($event->getSignal());
  }

  /**
   * Phase 4: inward and non-pending interrupts are NOT cancelled.
   *
   * The pipeline may have inward signals or already-cancelled outward
   * rows mixed in with pending outward ones — the subscriber must skip
   * those (cancel() hook never called, entity not flipped).
   */
  public function testCancelSkipsInwardAndNonPendingRows(): void {
    $dispatcher = $this->prophesize(InterruptCancellationDispatcher::class);
    $subscriber = new PipelineSignalPollSubscriber(
      $this->interruptManager->reveal(),
      $this->logger->reveal(),
      $dispatcher->reveal(),
    );

    $signal = $this->prophesize(FlowDropInterruptInterface::class);
    $signal->getType()->willReturn(InterruptType::Cancel);
    $signal->uuid()->willReturn('uuid-c');
    $signal->getReason()->willReturn(NULL);
    $signal->getInitiatorUid()->willReturn(NULL);
    $signal->acknowledge()->willReturn($signal->reveal());
    $signal->save()->shouldBeCalled();

    // Inward row (e.g. the signal itself) — must be skipped.
    $inward = $this->prophesize(FlowDropInterruptInterface::class);
    $inward->getDirection()->willReturn(InterruptDirection::Inward);
    $inward->getStatus()->willReturn(InterruptStatus::Pending);
    $inward->getJobId()->willReturn(NULL);
    $inward->cancel()->shouldNotBeCalled();

    // Already-resolved outward — must be skipped.
    $resolved = $this->prophesize(FlowDropInterruptInterface::class);
    $resolved->getDirection()->willReturn(InterruptDirection::Outward);
    $resolved->getStatus()->willReturn(InterruptStatus::Resolved);
    $resolved->cancel()->shouldNotBeCalled();

    $dispatcher->invokeCancelHook(Argument::any(), Argument::any())->shouldNotBeCalled();

    $this->interruptManager->findPendingInwardSignal('pipe_5')->willReturn($signal->reveal());
    $this->interruptManager->getPendingInterruptsForPipeline('pipe_5')->willReturn([
      $inward->reveal(),
      $resolved->reveal(),
    ]);

    $event = new PipelineSignalPollEvent(
      pipelineId: 'pipe_5',
      executionId: 'exec_5',
      workflowId: 'wf_5',
    );

    $subscriber->onPoll($event);
    $this->assertNotNull($event->getSignal());
  }

  /**
   * Manager exceptions are caught and logged — polling is best-effort.
   *
   * The pipeline keeps running if the lookup fails.
   */
  public function testManagerExceptionIsCaught(): void {
    $this->interruptManager->findPendingInwardSignal('pipe_3')
      ->willThrow(new \RuntimeException('storage offline'));
    $this->logger->error(Argument::any(), Argument::type('array'))->shouldBeCalled();

    $event = new PipelineSignalPollEvent(
      pipelineId: 'pipe_3',
      executionId: 'exec_3',
      workflowId: 'wf_3',
    );

    // No exception escapes.
    $this->subscriber->onPoll($event);
    $this->assertNull($event->getSignal());
  }

}
