<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_trigger\Unit\Service;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\flowdrop_orchestration\DTO\Orchestrator\OrchestrationRequest;
use Drupal\flowdrop_orchestration\DTO\Orchestrator\OrchestrationResponse;
use Drupal\flowdrop_orchestration\Service\Orchestrator\OrchestratorInterface;
use Drupal\flowdrop_trigger\Event\TriggerPostExecuteEvent;
use Drupal\flowdrop_trigger\Event\TriggerPreExecuteEvent;
use Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface;
use Drupal\flowdrop_trigger\Service\CronTriggerStateService;
use Drupal\flowdrop_trigger\Service\OrchestrationRequestBuilder;
use Drupal\flowdrop_trigger\Service\TriggerExecutor;
use Drupal\flowdrop_workflow\FlowDropWorkflowInterface;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

/**
 * Tests the TriggerExecutor service.
 *
 * @coversDefaultClass \Drupal\flowdrop_trigger\Service\TriggerExecutor
 * @group flowdrop_trigger
 */
class TriggerExecutorTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The trigger executor under test.
   *
   * @var \Drupal\flowdrop_trigger\Service\TriggerExecutor
   */
  private TriggerExecutor $executor;

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

  /**
   * The event dispatcher mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Symfony\Component\EventDispatcher\EventDispatcherInterface
   */
  private $eventDispatcher;

  /**
   * The orchestration request builder mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\flowdrop_trigger\Service\OrchestrationRequestBuilder
   */
  private $requestBuilder;

  /**
   * The cron trigger state service mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\flowdrop_trigger\Service\CronTriggerStateService
   */
  private $cronStateService;

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

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

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

    $this->entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
    $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
    $this->requestBuilder = $this->prophesize(OrchestrationRequestBuilder::class);
    $this->cronStateService = $this->prophesize(CronTriggerStateService::class);
    $this->logger = $this->prophesize(LoggerInterface::class);

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

    $this->workflowStorage = $this->prophesize(EntityStorageInterface::class);
    $this->entityTypeManager->getStorage('flowdrop_workflow')
      ->willReturn($this->workflowStorage->reveal());

    $this->executor = new TriggerExecutor(
      $this->entityTypeManager->reveal(),
      $this->eventDispatcher->reveal(),
      $loggerFactory->reveal(),
      $this->requestBuilder->reveal(),
      $this->cronStateService->reveal(),
    );
  }

  /**
   * @covers ::executeTrigger
   */
  public function testExecuteTriggerWorkflowNotFoundReturnsEarly(): void {
    $config = $this->createMockConfig('t1', 'missing_wf');
    $this->workflowStorage->load('missing_wf')->willReturn(NULL);

    $entity = $this->prophesize(EntityInterface::class);

    $this->executor->executeTrigger($config->reveal(), 'entity.insert', $entity->reveal(), [], NULL);

    $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled();
    $this->logger->error(Argument::containingString('Workflow not found'), Argument::any())
      ->shouldHaveBeenCalled();
  }

  /**
   * @covers ::executeTrigger
   */
  public function testExecuteTriggerNullConfigIdReturnsEarly(): void {
    $config = $this->prophesize(FlowDropTriggerConfigInterface::class);
    $config->getWorkflowId()->willReturn('wf');
    $config->id()->willReturn(NULL);

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $this->workflowStorage->load('wf')->willReturn($workflow->reveal());

    $entity = $this->prophesize(EntityInterface::class);

    $this->executor->executeTrigger($config->reveal(), 'entity.insert', $entity->reveal(), [], NULL);

    $this->logger->error(Argument::containingString('no ID'), Argument::any())->shouldHaveBeenCalled();
    $this->requestBuilder->getOrchestrator(Argument::cetera())->shouldNotHaveBeenCalled();
  }

  /**
   * @covers ::executeTrigger
   */
  public function testExecuteTriggerDispatchesPreAndPostEvents(): void {
    $config = $this->createMockConfig('t1', 'wf');
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $this->workflowStorage->load('wf')->willReturn($workflow->reveal());

    $entity = $this->prophesize(EntityInterface::class);
    $response = $this->prophesize(OrchestrationResponse::class);
    $response->getStatus()->willReturn('completed');

    $orchestrator = $this->prophesize(OrchestratorInterface::class);
    $orchestrator->orchestrate(Argument::any())->willReturn($response->reveal());
    $this->requestBuilder->getOrchestrator($config->reveal(), 'entity.insert')
      ->willReturn($orchestrator->reveal());
    $this->requestBuilder->buildEntityRequest(Argument::cetera())
      ->willReturn($this->prophesize(OrchestrationRequest::class)->reveal());

    $dispatchedEvents = [];
    $this->eventDispatcher->dispatch(Argument::any(), Argument::type('string'))
      ->will(function ($args) use (&$dispatchedEvents) {
        $dispatchedEvents[] = $args[1];
        return $args[0];
      });

    $this->executor->executeTrigger($config->reveal(), 'entity.insert', $entity->reveal(), [], NULL);

    $this->assertCount(2, $dispatchedEvents);
    $this->assertEquals(TriggerPreExecuteEvent::NAME, $dispatchedEvents[0]);
    $this->assertEquals(TriggerPostExecuteEvent::NAME, $dispatchedEvents[1]);
  }

  /**
   * @covers ::executeTrigger
   */
  public function testExecuteTriggerOrchestratorExceptionCaughtAndLogged(): void {
    $config = $this->createMockConfig('t1', 'wf');
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $this->workflowStorage->load('wf')->willReturn($workflow->reveal());

    $entity = $this->prophesize(EntityInterface::class);

    $orchestrator = $this->prophesize(OrchestratorInterface::class);
    $orchestrator->orchestrate(Argument::any())->willThrow(new \RuntimeException('Orchestrator failed'));
    $this->requestBuilder->getOrchestrator(Argument::cetera())->willReturn($orchestrator->reveal());
    $this->requestBuilder->buildEntityRequest(Argument::cetera())
      ->willReturn($this->prophesize(OrchestrationRequest::class)->reveal());

    $this->eventDispatcher->dispatch(Argument::any(), Argument::any())->willReturnArgument(0);

    // Should not throw - exception is caught internally.
    $this->executor->executeTrigger($config->reveal(), 'entity.insert', $entity->reveal(), [], NULL);

    $this->logger->error(Argument::containingString('failed'), Argument::any())
      ->shouldHaveBeenCalled();
  }

  /**
   * @covers ::executeFormTrigger
   */
  public function testExecuteFormTriggerWorkflowNotFoundReturnsEarly(): void {
    $config = $this->createMockConfig('t1', 'missing_wf');
    $this->workflowStorage->load('missing_wf')->willReturn(NULL);

    $this->executor->executeFormTrigger($config->reveal(), 'form.submit', 'my_form', [], []);

    $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled();
  }

  /**
   * @covers ::executeFormTrigger
   */
  public function testExecuteFormTriggerSuccessfulExecution(): void {
    $config = $this->createMockConfig('t1', 'wf');
    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $this->workflowStorage->load('wf')->willReturn($workflow->reveal());

    $response = $this->prophesize(OrchestrationResponse::class);
    $response->getStatus()->willReturn('completed');

    $orchestrator = $this->prophesize(OrchestratorInterface::class);
    $orchestrator->orchestrate(Argument::any())->willReturn($response->reveal());
    $this->requestBuilder->getOrchestrator(Argument::cetera())->willReturn($orchestrator->reveal());
    $this->requestBuilder->buildFormRequest(Argument::cetera())
      ->willReturn($this->prophesize(OrchestrationRequest::class)->reveal());

    $this->eventDispatcher->dispatch(Argument::any(), Argument::any())->willReturnArgument(0);

    $this->executor->executeFormTrigger($config->reveal(), 'form.submit', 'my_form', ['title' => 'Test'], []);

    $this->logger->info(Argument::containingString('executed successfully'), Argument::any())
      ->shouldHaveBeenCalled();
  }

  /**
   * @covers ::executeCronTrigger
   */
  public function testExecuteCronTriggerRecordsDurationOnSuccess(): void {
    $config = $this->createMockConfig('t1', 'wf');
    $config->getConditions()->willReturn([]);

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $this->workflowStorage->load('wf')->willReturn($workflow->reveal());

    $response = $this->prophesize(OrchestrationResponse::class);
    $response->getStatus()->willReturn('completed');

    $orchestrator = $this->prophesize(OrchestratorInterface::class);
    $orchestrator->orchestrate(Argument::any())->willReturn($response->reveal());
    $this->requestBuilder->getOrchestrator(Argument::cetera())->willReturn($orchestrator->reveal());
    $this->requestBuilder->buildCronRequest(Argument::cetera())
      ->willReturn($this->prophesize(OrchestrationRequest::class)->reveal());

    $this->eventDispatcher->dispatch(Argument::any(), Argument::any())->willReturnArgument(0);
    $this->cronStateService->getState('t1')->willReturn([]);
    $this->cronStateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);

    $this->executor->executeCronTrigger($config->reveal(), 'cron.run', [], []);

    $this->cronStateService->recordExecution('t1', 'success', Argument::type('string'), Argument::type('int'))
      ->shouldHaveBeenCalledOnce();
  }

  /**
   * @covers ::executeCronTrigger
   */
  public function testExecuteCronTriggerRecordsErrorOnFailure(): void {
    $config = $this->createMockConfig('t1', 'wf');

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $this->workflowStorage->load('wf')->willReturn($workflow->reveal());

    $orchestrator = $this->prophesize(OrchestratorInterface::class);
    $orchestrator->orchestrate(Argument::any())->willThrow(new \RuntimeException('Boom'));
    $this->requestBuilder->getOrchestrator(Argument::cetera())->willReturn($orchestrator->reveal());
    $this->requestBuilder->buildCronRequest(Argument::cetera())
      ->willReturn($this->prophesize(OrchestrationRequest::class)->reveal());

    $this->eventDispatcher->dispatch(Argument::any(), Argument::any())->willReturnArgument(0);
    $this->cronStateService->getState('t1')->willReturn([]);

    $this->executor->executeCronTrigger($config->reveal(), 'cron.run', [], []);

    $this->cronStateService->recordExecution('t1', 'error', 'Boom', Argument::type('int'))
      ->shouldHaveBeenCalledOnce();
  }

  /**
   * @covers ::handleMaxExecutionsReached
   */
  public function testCronTriggerDisabledWhenMaxExecutionsReached(): void {
    $config = $this->createMockConfig('t1', 'wf');
    $config->getConditions()->willReturn([
      'cron_schedule' => ['max_executions' => 5],
    ]);
    $config->set('status', FALSE)->willReturn($config->reveal());
    $config->save()->shouldBeCalled();

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $this->workflowStorage->load('wf')->willReturn($workflow->reveal());

    $response = $this->prophesize(OrchestrationResponse::class);
    $response->getStatus()->willReturn('completed');

    $orchestrator = $this->prophesize(OrchestratorInterface::class);
    $orchestrator->orchestrate(Argument::any())->willReturn($response->reveal());
    $this->requestBuilder->getOrchestrator(Argument::cetera())->willReturn($orchestrator->reveal());
    $this->requestBuilder->buildCronRequest(Argument::cetera())
      ->willReturn($this->prophesize(OrchestrationRequest::class)->reveal());

    $this->eventDispatcher->dispatch(Argument::any(), Argument::any())->willReturnArgument(0);
    $this->cronStateService->getState('t1')->willReturn([]);
    $this->cronStateService->recordExecution(Argument::cetera())->shouldBeCalled();
    $this->cronStateService->hasReachedMaxExecutions('t1', 5)->willReturn(TRUE);

    $this->executor->executeCronTrigger($config->reveal(), 'cron.run', [], []);

    $this->logger->info(Argument::containingString('disabled'), Argument::any())
      ->shouldHaveBeenCalled();
  }

  /**
   * @covers ::handleMaxExecutionsReached
   */
  public function testMaxExecutionsZeroMeansUnlimited(): void {
    $config = $this->createMockConfig('t1', 'wf');
    $config->getConditions()->willReturn([
      'cron_schedule' => ['max_executions' => 0],
    ]);

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $this->workflowStorage->load('wf')->willReturn($workflow->reveal());

    $response = $this->prophesize(OrchestrationResponse::class);
    $response->getStatus()->willReturn('completed');

    $orchestrator = $this->prophesize(OrchestratorInterface::class);
    $orchestrator->orchestrate(Argument::any())->willReturn($response->reveal());
    $this->requestBuilder->getOrchestrator(Argument::cetera())->willReturn($orchestrator->reveal());
    $this->requestBuilder->buildCronRequest(Argument::cetera())
      ->willReturn($this->prophesize(OrchestrationRequest::class)->reveal());

    $this->eventDispatcher->dispatch(Argument::any(), Argument::any())->willReturnArgument(0);
    $this->cronStateService->getState('t1')->willReturn([]);
    $this->cronStateService->recordExecution(Argument::cetera())->shouldBeCalled();

    $this->executor->executeCronTrigger($config->reveal(), 'cron.run', [], []);

    // hasReachedMaxExecutions should NOT be called when max_executions is 0.
    $this->cronStateService->hasReachedMaxExecutions(Argument::cetera())->shouldNotHaveBeenCalled();
    // Config should NOT be disabled.
    $config->set(Argument::cetera())->shouldNotHaveBeenCalled();
  }

  /**
   * @covers ::executeCronTrigger
   */
  public function testExecuteCronTriggerWithNullStateService(): void {
    // Create executor WITHOUT cron state service.
    $loggerFactory = $this->prophesize(LoggerChannelFactoryInterface::class);
    // @phpstan-ignore-next-line
    $loggerFactory->get('flowdrop_trigger')->willReturn($this->logger->reveal());

    $executor = new TriggerExecutor(
      $this->entityTypeManager->reveal(),
      $this->eventDispatcher->reveal(),
      $loggerFactory->reveal(),
      $this->requestBuilder->reveal(),
      NULL,
    );

    $config = $this->createMockConfig('t1', 'wf');
    $config->getConditions()->willReturn([]);

    $workflow = $this->prophesize(FlowDropWorkflowInterface::class);
    $this->workflowStorage->load('wf')->willReturn($workflow->reveal());

    $response = $this->prophesize(OrchestrationResponse::class);
    $response->getStatus()->willReturn('completed');

    $orchestrator = $this->prophesize(OrchestratorInterface::class);
    $orchestrator->orchestrate(Argument::any())->willReturn($response->reveal());
    $this->requestBuilder->getOrchestrator(Argument::cetera())->willReturn($orchestrator->reveal());
    $this->requestBuilder->buildCronRequest(Argument::cetera())
      ->willReturn($this->prophesize(OrchestrationRequest::class)->reveal());

    $this->eventDispatcher->dispatch(Argument::any(), Argument::any())->willReturnArgument(0);

    // Should not throw even without state service.
    $executor->executeCronTrigger($config->reveal(), 'cron.run', [], []);

    $this->cronStateService->recordExecution(Argument::cetera())->shouldNotHaveBeenCalled();
  }

  /**
   * Creates a mock trigger config.
   *
   * @return \Prophecy\Prophecy\ObjectProphecy<\Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface>
   *   The mock trigger config.
   */
  private function createMockConfig(string $id, string $workflowId) {
    $config = $this->prophesize(FlowDropTriggerConfigInterface::class);
    $config->id()->willReturn($id);
    $config->getWorkflowId()->willReturn($workflowId);
    return $config;
  }

}
