<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_interrupt\Unit\EventSubscriber;

use Drupal\flowdrop_interrupt\Entity\FlowDropInterruptInterface;
use Drupal\flowdrop_interrupt\Event\InterruptResolvedEvent;
use Drupal\flowdrop_interrupt\EventSubscriber\LinkedInterruptResolvedSubscriber;
use Drupal\flowdrop_interrupt\Service\InterruptManagerInterface;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;

/**
 * Tests the cycle guard added in the C4 fix.
 *
 * The cascade subscriber resolves followers when an interrupt resolves; the
 * follower's own resolution fires another InterruptResolvedEvent which
 * re-enters this subscriber. Without a cycle guard, A→B→A links recurse
 * until lock contention or stack overflow. The fix tracks in-flight UUIDs
 * and a depth cap.
 *
 * @coversDefaultClass \Drupal\flowdrop_interrupt\EventSubscriber\LinkedInterruptResolvedSubscriber
 * @group flowdrop_interrupt
 */
class LinkedInterruptResolvedSubscriberCycleTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The mocked interrupt manager.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy<InterruptManagerInterface>
   */
  private $manager;

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

  /**
   * The subscriber under test.
   */
  private LinkedInterruptResolvedSubscriber $subscriber;

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

  /**
   * A→B link resolves the follower exactly once. No cycle, no error log.
   */
  public function testSimpleCascadeResolves(): void {
    $b = $this->buildInterrupt('uuid-b');
    $this->manager->getPendingInterruptsLinkedTo('uuid-a')->willReturn([$b]);
    $this->manager->resolveInterrupt('uuid-b', ['payload' => 1], 42)
      ->shouldBeCalledOnce()
      ->willReturn(NULL);
    $this->logger->error(Argument::cetera())->shouldNotBeCalled();

    $event = $this->buildResolvedEvent('uuid-a', ['payload' => 1], 42);
    $this->subscriber->onInterruptResolved($event);
  }

  /**
   * Tests that a direct A→B→A cycle is detected and skipped.
   *
   * When B's resolution re-fires the event with B's follower being A
   * (already in-flight), the subscriber logs and skips instead of recursing.
   */
  public function testDirectCycleDetectedAndSkipped(): void {
    $a = $this->buildInterrupt('uuid-a');
    $b = $this->buildInterrupt('uuid-b');

    // First call: resolving A finds B as follower.
    $this->manager->getPendingInterruptsLinkedTo('uuid-a')->willReturn([$b]);
    // The resolveInterrupt call would normally fire a new event for B; we
    // simulate that by re-entering directly. The cycle guard should detect
    // that A is already in flight when B's cascade tries to resolve A.
    $this->manager->resolveInterrupt('uuid-b', Argument::cetera())
      ->will(function () {
        // Re-enter: B's resolution triggers a cascade lookup for B's
        // followers; B is linked to A, so A appears as a follower.
        // The subscriber's $inFlight set must contain uuid-a at this point.
      });
    // When the test reaches getPendingInterruptsLinkedTo('uuid-b'), it
    // would return [$a]. That's the cycle.
    $this->manager->getPendingInterruptsLinkedTo('uuid-b')->willReturn([$a]);

    // Expect at least one cycle-detected error log.
    $this->logger->error(Argument::containingString('cycle'), Argument::any())
      ->shouldBeCalled();

    // The recursion is driven by us calling the subscriber a second time
    // from inside the resolveInterrupt mock — that mirrors what the real
    // event dispatcher would do when B resolves.
    $self = $this;
    $this->manager->resolveInterrupt('uuid-b', Argument::cetera())
      ->will(function () use ($self) {
        // Simulate the dispatcher firing the resolved event for B.
        $event = $self->buildResolvedEvent('uuid-b', [], NULL);
        $self->subscriber->onInterruptResolved($event);
      });

    // Resolving A through the cascade chain MUST NOT also re-resolve A.
    $this->manager->resolveInterrupt('uuid-a', Argument::cetera())
      ->shouldNotBeCalled();

    $event = $this->buildResolvedEvent('uuid-a', [], NULL);
    $this->subscriber->onInterruptResolved($event);
  }

  /**
   * Tests that self-linked interrupts are silently skipped.
   *
   * An interrupt that lists itself as a follower is silently skipped by the
   * pre-existing self-link guard.
   */
  public function testSelfLinkSilentlySkipped(): void {
    $self = $this->buildInterrupt('uuid-self');
    $this->manager->getPendingInterruptsLinkedTo('uuid-self')->willReturn([$self]);
    $this->manager->resolveInterrupt('uuid-self', Argument::cetera())->shouldNotBeCalled();
    $this->logger->error(Argument::cetera())->shouldNotBeCalled();

    $event = $this->buildResolvedEvent('uuid-self', [], NULL);
    $this->subscriber->onInterruptResolved($event);
  }

  /**
   * Builds an event for a given resolved UUID.
   *
   * @internal Public for use by callbacks within the test.
   */
  public function buildResolvedEvent(string $uuid, mixed $responseData, ?int $resolvedBy): InterruptResolvedEvent {
    $interrupt = $this->buildInterrupt($uuid);
    return new InterruptResolvedEvent(
      interrupt: $interrupt,
      responseData: $responseData,
      resolvedBy: $resolvedBy,
    );
  }

  /**
   * Builds a minimal interrupt entity stub returning the given UUID.
   *
   * @internal
   */
  private function buildInterrupt(string $uuid): FlowDropInterruptInterface {
    $interrupt = $this->prophesize(FlowDropInterruptInterface::class);
    $interrupt->uuid()->willReturn($uuid);
    return $interrupt->reveal();
  }

}
