<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_interrupt\Unit\EventSubscriber;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop_interrupt\DTO\ExecutionStamp;
use Drupal\flowdrop_interrupt\Entity\FlowDropInterruptInterface;
use Drupal\flowdrop_interrupt\Enum\InterruptType;
use Drupal\flowdrop_interrupt\EventSubscriber\JobInterruptCaughtSubscriber;
use Drupal\flowdrop_interrupt\Exception\InterruptRequiredException;
use Drupal\flowdrop_interrupt\Message\HitlInterruptMessage;
use Drupal\flowdrop_interrupt\Service\InterruptMessageBusInterface;
use Drupal\flowdrop_job\FlowDropJobInterface;
use Drupal\flowdrop_orchestration\Exception\InterruptExceptionInterface;
use Drupal\flowdrop_runtime\Event\JobInterruptCaughtEvent;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Log\LoggerInterface;

/**
 * Tests JobInterruptCaughtSubscriber's dispatch + mark-job behavior.
 *
 * @coversDefaultClass \Drupal\flowdrop_interrupt\EventSubscriber\JobInterruptCaughtSubscriber
 * @group flowdrop_interrupt
 */
class JobInterruptCaughtSubscriberTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The mocked message bus.
   */
  private ObjectProphecy $messageBus;

  /**
   * The mocked entity type manager.
   */
  private ObjectProphecy $entityTypeManager;

  /**
   * The mocked job storage.
   */
  private ObjectProphecy $jobStorage;

  /**
   * The mocked logger.
   */
  private ObjectProphecy $logger;

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

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

    $this->subscriber = new JobInterruptCaughtSubscriber(
      $this->messageBus->reveal(),
      $this->entityTypeManager->reveal(),
      $this->logger->reveal(),
    );
  }

  /**
   * Message shape: bus dispatches with stamp, job is marked, entity attached.
   */
  public function testMessageShapeDispatchesMarksAndAttaches(): void {
    $message = new HitlInterruptMessage(
      type: InterruptType::SchemaForm,
      message: 'Approve?',
      schema: ['type' => 'boolean'],
    );
    $exception = new InterruptRequiredException($message);

    $persistedEntity = $this->prophesize(FlowDropInterruptInterface::class);
    $persistedEntity->uuid()->willReturn('uuid-new');

    $this->messageBus->dispatch(
      $message,
      Argument::that(fn (ExecutionStamp $s) =>
        $s->workflowId === 'wf_1'
        && $s->nodeId === 'node_1'
        && $s->pipelineId === 'pipe_1'
        && $s->sessionId === NULL
        && $s->jobId === 99
      ),
    )->shouldBeCalled()->willReturn($persistedEntity->reveal());

    $job = $this->prophesize(FlowDropJobInterface::class);
    $job->markAsInterrupted('uuid-new', Argument::type('string'))
      ->shouldBeCalled()
      ->willReturn($job->reveal());
    $this->jobStorage->load(99)->willReturn($job->reveal());
    $this->jobStorage->save($job->reveal())->shouldBeCalled();

    $event = new JobInterruptCaughtEvent(
      exception: $exception,
      workflowId: 'wf_1',
      pipelineId: 'pipe_1',
      nodeId: 'node_1',
      jobId: 99,
      sessionId: NULL,
    );

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

    $this->assertSame($persistedEntity->reveal(), $event->getInterrupt());
  }

  /**
   * Pre-persisted entity with missing job_id: stamps it and attaches entity.
   */
  public function testEntityShapeStampsJobIdAndAttaches(): void {
    $entity = $this->prophesize(FlowDropInterruptInterface::class);
    $entity->getJobId()->willReturn(NULL);
    $entity->setJobId(99)->shouldBeCalled()->willReturn($entity->reveal());
    $entity->save()->shouldBeCalledTimes(1);

    $exception = new InterruptRequiredException($entity->reveal());

    $event = new JobInterruptCaughtEvent(
      exception: $exception,
      workflowId: 'wf_1',
      pipelineId: 'pipe_1',
      nodeId: 'node_1',
      jobId: 99,
      sessionId: NULL,
    );

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

    $this->assertSame($entity->reveal(), $event->getInterrupt());
  }

  /**
   * Pre-persisted entity with already-populated job_id: no save call.
   */
  public function testEntityShapeWithJobIdDoesNotResave(): void {
    $entity = $this->prophesize(FlowDropInterruptInterface::class);
    $entity->getJobId()->willReturn(99);
    $entity->setJobId(Argument::any())->shouldNotBeCalled();
    $entity->save()->shouldNotBeCalled();

    $exception = new InterruptRequiredException($entity->reveal());

    $event = new JobInterruptCaughtEvent(
      exception: $exception,
      workflowId: 'wf_1',
      pipelineId: 'pipe_1',
      nodeId: 'node_1',
      jobId: 99,
      sessionId: NULL,
    );

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

    $this->assertSame($entity->reveal(), $event->getInterrupt());
  }

  /**
   * Pre-persisted entity with no job_id from event: no stamp.
   */
  public function testEntityShapeWithNullEventJobIdDoesNothing(): void {
    $entity = $this->prophesize(FlowDropInterruptInterface::class);
    $entity->getJobId()->willReturn(NULL);
    $entity->setJobId(Argument::any())->shouldNotBeCalled();
    $entity->save()->shouldNotBeCalled();

    $exception = new InterruptRequiredException($entity->reveal());

    $event = new JobInterruptCaughtEvent(
      exception: $exception,
      workflowId: 'wf_1',
      pipelineId: NULL,
      nodeId: 'node_1',
      jobId: NULL,
      sessionId: NULL,
    );

    $this->subscriber->onInterruptCaught($event);
    $this->assertSame($entity->reveal(), $event->getInterrupt());
  }

  /**
   * Message shape with no job_id: dispatches but skips job marking.
   */
  public function testMessageShapeWithoutJobIdSkipsJobMarking(): void {
    $message = new HitlInterruptMessage(message: 'm', schema: []);
    $exception = new InterruptRequiredException($message);

    $entity = $this->prophesize(FlowDropInterruptInterface::class);
    $entity->uuid()->willReturn('uuid-x');
    $this->messageBus->dispatch(Argument::cetera())->willReturn($entity->reveal());

    // jobStorage->load must not be called when jobId is NULL.
    $this->jobStorage->load(Argument::any())->shouldNotBeCalled();

    $event = new JobInterruptCaughtEvent(
      exception: $exception,
      workflowId: 'wf',
      pipelineId: NULL,
      nodeId: 'n',
      jobId: NULL,
      sessionId: NULL,
    );

    $this->subscriber->onInterruptCaught($event);
    $this->assertSame($entity->reveal(), $event->getInterrupt());
  }

  /**
   * Third-party InterruptExceptionInterface impls are ignored.
   */
  public function testUnrelatedExceptionShapeIsIgnored(): void {
    $foreign = $this->prophesize(InterruptExceptionInterface::class);

    $this->messageBus->dispatch(Argument::cetera())->shouldNotBeCalled();
    $this->jobStorage->load(Argument::any())->shouldNotBeCalled();

    $event = new JobInterruptCaughtEvent(
      exception: $foreign->reveal(),
      workflowId: 'wf',
      pipelineId: NULL,
      nodeId: 'n',
      jobId: NULL,
      sessionId: NULL,
    );

    $this->subscriber->onInterruptCaught($event);
    $this->assertNull($event->getInterrupt());
  }

  /**
   * Bus errors propagate — the subscriber does not swallow them.
   */
  public function testDispatchErrorPropagates(): void {
    $message = new HitlInterruptMessage(message: 'm', schema: []);
    $exception = new InterruptRequiredException($message);

    $this->messageBus->dispatch(Argument::cetera())
      ->willThrow(new \RuntimeException('storage offline'));

    $event = new JobInterruptCaughtEvent(
      exception: $exception,
      workflowId: 'wf',
      pipelineId: 'p',
      nodeId: 'n',
      jobId: 1,
      sessionId: NULL,
    );

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage('storage offline');
    $this->subscriber->onInterruptCaught($event);
  }

  /**
   * Stale jobId (job no longer exists) throws.
   */
  public function testStaleJobIdThrows(): void {
    $message = new HitlInterruptMessage(message: 'm', schema: []);
    $exception = new InterruptRequiredException($message);

    $entity = $this->prophesize(FlowDropInterruptInterface::class);
    $entity->uuid()->willReturn('uuid-x');
    $this->messageBus->dispatch(Argument::cetera())->willReturn($entity->reveal());

    $this->jobStorage->load(123)->willReturn(NULL);

    $event = new JobInterruptCaughtEvent(
      exception: $exception,
      workflowId: 'wf',
      pipelineId: 'p',
      nodeId: 'n',
      jobId: 123,
      sessionId: NULL,
    );

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessageMatches('/job 123 not found/');
    $this->subscriber->onInterruptCaught($event);
  }

}
