<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_workflow_executor\Unit\Service;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Queue\QueueFactory;
use Drupal\Core\Queue\QueueInterface;
use Drupal\Core\State\StateInterface;
use Drupal\flowdrop\Service\EntitySerializer;
use Drupal\flowdrop_session\Entity\FlowDropSessionInterface;
use Drupal\flowdrop_session\Entity\FlowDropSessionMessageInterface;
use Drupal\flowdrop_session\Service\MessageProcessorInterface;
use Drupal\flowdrop_session\Service\SessionService;
use Drupal\flowdrop_workflow\FlowDropWorkflowInterface;
use Drupal\flowdrop_workflow_executor\Enum\WorkflowExecutionMode;
use Drupal\flowdrop_workflow_executor\Enum\WorkflowExecutionStatus;
use Drupal\flowdrop_workflow_executor\Service\WorkflowExecutionService;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;

/**
 * Tests the WorkflowExecutionService.
 *
 * @coversDefaultClass \Drupal\flowdrop_workflow_executor\Service\WorkflowExecutionService
 * @group flowdrop_workflow_executor
 */
class WorkflowExecutionServiceTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The workflow execution service under test.
   *
   * @var \Drupal\flowdrop_workflow_executor\Service\WorkflowExecutionService
   */
  private WorkflowExecutionService $service;

  /**
   * The entity type manager mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\Core\Entity\EntityTypeManagerInterface
   */
  private $entityTypeManager;

  /**
   * The session service mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\flowdrop_session\Service\SessionService
   */
  private $sessionService;

  /**
   * The entity serializer mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\flowdrop\Service\EntitySerializer
   */
  private $entitySerializer;

  /**
   * The message processor mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\flowdrop_session\Service\MessageProcessorInterface
   */
  private $messageProcessor;

  /**
   * The state service mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\Core\State\StateInterface
   */
  private $state;

  /**
   * The queue mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\Core\Queue\QueueInterface
   */
  private $queue;

  /**
   * The logger mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Psr\Log\LoggerInterface
   */
  private $logger;

  /**
   * The session storage mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\Core\Entity\EntityStorageInterface
   */
  private $sessionStorage;

  /**
   * The message storage mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\Core\Entity\EntityStorageInterface
   */
  private $messageStorage;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $this->entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
    $this->sessionService = $this->prophesize(SessionService::class);
    $this->entitySerializer = $this->prophesize(EntitySerializer::class);
    $this->messageProcessor = $this->prophesize(MessageProcessorInterface::class);
    $this->state = $this->prophesize(StateInterface::class);
    $this->queue = $this->prophesize(QueueInterface::class);
    $this->logger = $this->prophesize(LoggerInterface::class);

    $queueFactory = $this->prophesize(QueueFactory::class);
    $queueFactory->get('workflow_executor_queue')->willReturn($this->queue->reveal());

    $loggerFactory = $this->prophesize(LoggerChannelFactoryInterface::class);
    // @phpstan-ignore-next-line
    $loggerFactory->get('workflow_executor')->willReturn($this->logger->reveal());

    $this->sessionStorage = $this->prophesize(EntityStorageInterface::class);
    $this->messageStorage = $this->prophesize(EntityStorageInterface::class);

    $this->entityTypeManager->getStorage('flowdrop_session')
      ->willReturn($this->sessionStorage->reveal());
    $this->entityTypeManager->getStorage('flowdrop_session_message')
      ->willReturn($this->messageStorage->reveal());

    $this->service = new WorkflowExecutionService(
      $this->entityTypeManager->reveal(),
      $this->sessionService->reveal(),
      $this->entitySerializer->reveal(),
      $this->messageProcessor->reveal(),
      $this->state->reveal(),
      $queueFactory->reveal(),
      $loggerFactory->reveal(),
    );
  }

  /**
   * @covers ::execute
   */
  public function testExecuteMaxDepthExceededReturnsFailure(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn('wf_deep');

    $result = $this->service->execute(
      $workflow->reveal(),
      [],
      WorkflowExecutionMode::Job,
      300,
      2000,
      ['execution_depth' => 5],
    );

    $this->assertEquals(WorkflowExecutionStatus::Failed, $result->getStatus());
    $this->assertStringContainsString('Maximum execution depth', $result->getErrorMessage());
  }

  /**
   * @covers ::execute
   */
  public function testExecuteCircularReferenceDetected(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn('wf_a');

    $result = $this->service->execute(
      $workflow->reveal(),
      [],
      WorkflowExecutionMode::Job,
      300,
      2000,
      ['workflow_chain' => ['wf_b', 'wf_a']],
    );

    $this->assertEquals(WorkflowExecutionStatus::Failed, $result->getStatus());
    $this->assertStringContainsString('Circular workflow reference', $result->getErrorMessage());
  }

  /**
   * @covers ::execute
   */
  public function testExecuteFireAndForgetQueuesAndReturnsPending(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn('wf');

    $this->queue->createItem(Argument::type('array'))->shouldBeCalled();

    $result = $this->service->execute(
      $workflow->reveal(),
      ['key' => 'value'],
      WorkflowExecutionMode::JobFireForget,
    );

    $this->assertEquals(WorkflowExecutionStatus::Pending, $result->getStatus());
    $this->assertStringStartsWith('queued_', $result->getSessionId());
  }

  /**
   * @covers ::execute
   */
  public function testExecuteJobModeSessionCreationFailure(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn('wf');
    $workflow->label()->willReturn('My Workflow');

    $this->sessionService->createSession(Argument::cetera())
      ->willThrow(new \RuntimeException('DB error'));

    $result = $this->service->execute(
      $workflow->reveal(),
      [],
      WorkflowExecutionMode::Job,
    );

    $this->assertEquals(WorkflowExecutionStatus::Failed, $result->getStatus());
    $this->assertStringContainsString('Failed to create', $result->getErrorMessage());
  }

  /**
   * @covers ::execute
   */
  public function testExecuteJobAsyncReturnsPendingWithSessionId(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn('wf');
    $workflow->label()->willReturn('My Workflow');

    $session = $this->createMockSession('42', 'idle');
    $session->setMetadata(Argument::any())->willReturn($session->reveal());
    $session->save()->shouldBeCalled();

    $this->sessionService->createSession(Argument::cetera())->willReturn($session->reveal());

    // Message creation.
    $message = $this->createMockMessage();
    $this->messageStorage->create(Argument::type('array'))->willReturn($message->reveal());

    $result = $this->service->execute(
      $workflow->reveal(),
      [],
      WorkflowExecutionMode::JobAsync,
    );

    $this->assertEquals(WorkflowExecutionStatus::Pending, $result->getStatus());
    $this->assertEquals('42', $result->getSessionId());
  }

  /**
   * @covers ::execute
   */
  public function testExecuteJobModeSuccessfulSynchronous(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn('wf');
    $workflow->label()->willReturn('My Workflow');

    $session = $this->createMockSession('42', 'idle');
    $session->setMetadata(Argument::any())->willReturn($session->reveal());
    $session->save()->shouldBeCalled();

    $this->sessionService->createSession(Argument::cetera())->willReturn($session->reveal());

    $message = $this->createMockMessage();
    $this->messageStorage->create(Argument::type('array'))->willReturn($message->reveal());

    $this->messageProcessor->processMessage(Argument::any())->shouldBeCalled();

    // After processing, reload session.
    $this->sessionStorage->load('42')->willReturn($session->reveal());

    // No assistant messages.
    $this->messageStorage->loadByProperties(Argument::any())->willReturn([]);

    $result = $this->service->execute(
      $workflow->reveal(),
      [],
      WorkflowExecutionMode::Job,
    );

    $this->assertEquals(WorkflowExecutionStatus::Completed, $result->getStatus());
    $this->assertEquals('42', $result->getSessionId());
  }

  /**
   * @covers ::execute
   */
  public function testExecuteJobModeMessageProcessingException(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn('wf');
    $workflow->label()->willReturn('My Workflow');

    $session = $this->createMockSession('42', 'idle');
    $session->setMetadata(Argument::any())->willReturn($session->reveal());
    $session->save()->shouldBeCalled();

    $this->sessionService->createSession(Argument::cetera())->willReturn($session->reveal());

    $message = $this->createMockMessage();
    $this->messageStorage->create(Argument::type('array'))->willReturn($message->reveal());

    $this->messageProcessor->processMessage(Argument::any())
      ->willThrow(new \RuntimeException('Processing failed'));

    $result = $this->service->execute(
      $workflow->reveal(),
      [],
      WorkflowExecutionMode::Job,
    );

    $this->assertEquals(WorkflowExecutionStatus::Failed, $result->getStatus());
    $this->assertStringContainsString('Processing failed', $result->getErrorMessage());
  }

  /**
   * @covers ::execute
   */
  public function testExecuteJobModeSessionFailedAfterProcessing(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn('wf');
    $workflow->label()->willReturn('My Workflow');

    $session = $this->createMockSession('42', 'idle');
    $session->setMetadata(Argument::any())->willReturn($session->reveal());
    $session->save()->shouldBeCalled();

    $this->sessionService->createSession(Argument::cetera())->willReturn($session->reveal());

    $message = $this->createMockMessage();
    $this->messageStorage->create(Argument::type('array'))->willReturn($message->reveal());

    $this->messageProcessor->processMessage(Argument::any())->shouldBeCalled();

    // After processing, session is in failed state.
    $failedSession = $this->createMockSession('42', 'failed');
    $this->sessionStorage->load('42')->willReturn($failedSession->reveal());

    $result = $this->service->execute(
      $workflow->reveal(),
      [],
      WorkflowExecutionMode::Job,
    );

    $this->assertEquals(WorkflowExecutionStatus::Failed, $result->getStatus());
  }

  /**
   * @covers ::getResults
   */
  public function testGetResultsNoWaitSessionNotFound(): void {
    $this->sessionStorage->load('99')->willReturn(NULL);

    $result = $this->service->getResults('99', FALSE);

    $this->assertEquals(WorkflowExecutionStatus::Failed, $result->getStatus());
    $this->assertStringContainsString('Session not found', $result->getErrorMessage());
  }

  /**
   * @covers ::getResults
   */
  public function testGetResultsNoWaitRunning(): void {
    $session = $this->createMockSession('42', 'running');
    $this->sessionStorage->load('42')->willReturn($session->reveal());

    $result = $this->service->getResults('42', FALSE);

    $this->assertEquals(WorkflowExecutionStatus::Running, $result->getStatus());
  }

  /**
   * @covers ::isComplete
   */
  public function testIsCompleteReturnsTrueForCompleted(): void {
    $session = $this->createMockSession('42', 'idle');
    $this->sessionStorage->load('42')->willReturn($session->reveal());

    $this->assertTrue($this->service->isComplete('42'));
  }

  /**
   * @covers ::isComplete
   */
  public function testIsCompleteReturnsTrueForFailed(): void {
    $session = $this->createMockSession('42', 'failed');
    $this->sessionStorage->load('42')->willReturn($session->reveal());

    $this->assertTrue($this->service->isComplete('42'));
  }

  /**
   * @covers ::isComplete
   */
  public function testIsCompleteReturnsFalseForRunning(): void {
    $session = $this->createMockSession('42', 'running');
    $this->sessionStorage->load('42')->willReturn($session->reveal());

    $this->assertFalse($this->service->isComplete('42'));
  }

  /**
   * @covers ::isComplete
   */
  public function testIsCompleteReturnsFalseForMissingSession(): void {
    $this->sessionStorage->load('99')->willReturn(NULL);

    $this->assertFalse($this->service->isComplete('99'));
  }

  /**
   * @covers ::getStatus
   */
  public function testGetStatusReturnsFailedForMissingSession(): void {
    $this->sessionStorage->load('99')->willReturn(NULL);

    $this->assertEquals(WorkflowExecutionStatus::Failed, $this->service->getStatus('99'));
  }

  /**
   * @covers ::getStatus
   */
  public function testGetStatusMapsIdleToCompleted(): void {
    $session = $this->createMockSession('42', 'idle');
    $this->sessionStorage->load('42')->willReturn($session->reveal());

    $this->assertEquals(WorkflowExecutionStatus::Completed, $this->service->getStatus('42'));
  }

  /**
   * @covers ::getStatus
   */
  public function testGetStatusMapsRunningToRunning(): void {
    $session = $this->createMockSession('42', 'running');
    $this->sessionStorage->load('42')->willReturn($session->reveal());

    $this->assertEquals(WorkflowExecutionStatus::Running, $this->service->getStatus('42'));
  }

  /**
   * @covers ::getStatus
   */
  public function testGetStatusMapsUnknownToPending(): void {
    $session = $this->createMockSession('42', 'awaiting_input');
    $this->sessionStorage->load('42')->willReturn($session->reveal());

    $this->assertEquals(WorkflowExecutionStatus::Pending, $this->service->getStatus('42'));
  }

  /**
   * @covers ::execute
   */
  public function testExecuteDepthOneWithEmptyParentContext(): void {
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $workflow->id()->willReturn('wf');
    $workflow->label()->willReturn('Test');

    $session = $this->createMockSession('1', 'idle');
    $session->setMetadata(Argument::any())->willReturn($session->reveal());
    $session->save()->shouldBeCalled();

    $this->sessionService->createSession(Argument::cetera())->willReturn($session->reveal());

    $message = $this->createMockMessage();
    $this->messageStorage->create(Argument::type('array'))->willReturn($message->reveal());

    $this->messageProcessor->processMessage(Argument::any())->shouldBeCalled();
    $this->sessionStorage->load('1')->willReturn($session->reveal());
    $this->messageStorage->loadByProperties(Argument::any())->willReturn([]);

    // Empty parent context should work (depth becomes 1).
    $result = $this->service->execute($workflow->reveal());

    $this->assertEquals(WorkflowExecutionStatus::Completed, $result->getStatus());
  }

  /**
   * Creates a mock session with the given ID and status.
   *
   * @return \Prophecy\Prophecy\ObjectProphecy<\Drupal\flowdrop_session\Entity\FlowDropSessionInterface>
   *   The mock session.
   */
  private function createMockSession(string $id, string $status) {
    $session = $this->prophesize(FlowDropSessionInterface::class);
    $session->id()->willReturn($id);
    $session->getStatus()->willReturn($status);
    return $session;
  }

  /**
   * Creates a mock session message.
   *
   * @return \Prophecy\Prophecy\ObjectProphecy<\Drupal\flowdrop_session\Entity\FlowDropSessionMessageInterface>
   *   The mock message.
   */
  private function createMockMessage() {
    $message = $this->prophesize(FlowDropSessionMessageInterface::class);
    $message->save()->shouldBeCalled();
    $message->setMessageMetadata(Argument::type('array'))->willReturn($message->reveal());
    return $message;
  }

}
