<?php

namespace Drupal\Tests\eb\Unit\Service;

use Drupal\eb\Entity\EbRollbackOperationInterface;
use Drupal\eb\Event\OperationEvents;
use Drupal\eb\Event\OperationPostExecuteEvent;
use Drupal\eb\Event\OperationPreExecuteEvent;
use Drupal\eb\PluginInterfaces\OperationInterface;
use Drupal\eb\Result\ExecutionResult;
use Drupal\eb\Service\OperationProcessor;
use Drupal\eb\Service\RollbackManager;
use Drupal\Tests\eb\Unit\EbUnitTestBase;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * Unit tests for OperationProcessor service.
 *
 * @coversDefaultClass \Drupal\eb\Service\OperationProcessor
 * @group eb
 */
class OperationProcessorTest extends EbUnitTestBase {

  /**
   * The operation processor service under test.
   */
  protected OperationProcessor $operationProcessor;

  /**
   * Mock event dispatcher.
   */
  protected EventDispatcherInterface|MockObject $mockEventDispatcher;

  /**
   * Mock rollback manager.
   */
  protected RollbackManager|MockObject $mockRollbackManager;

  /**
   * Mock logger.
   */
  protected LoggerInterface|MockObject $mockLogger;

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

    $this->mockEventDispatcher = $this->createMock(EventDispatcherInterface::class);
    $this->mockRollbackManager = $this->createMock(RollbackManager::class);
    $this->mockLogger = $this->createMock(LoggerInterface::class);

    $this->operationProcessor = new OperationProcessor(
      $this->mockEventDispatcher,
      $this->mockRollbackManager,
      $this->mockLogger
    );
  }

  /**
   * Tests executeOperation with successful operation.
   *
   * @covers ::executeOperation
   */
  public function testExecuteOperationSuccess(): void {
    $executionResult = new ExecutionResult(TRUE);
    $executionResult->setRollbackData(['bundle_id' => 'article']);

    $mockOperation = $this->createMock(OperationInterface::class);
    $mockOperation->expects($this->once())
      ->method('execute')
      ->willReturn($executionResult);

    // Event dispatcher called twice (pre and post).
    $this->mockEventDispatcher
      ->expects($this->exactly(2))
      ->method('dispatch')
      ->willReturnCallback(function ($event, $eventName) {
        return $event;
      });

    // Rollback manager should store data.
    $mockRollbackOperation = $this->createMock(EbRollbackOperationInterface::class);
    $this->mockRollbackManager
      ->expects($this->once())
      ->method('storeRollbackData')
      ->willReturn($mockRollbackOperation);

    $result = $this->operationProcessor->executeOperation($mockOperation);

    $this->assertTrue($result->isSuccess());
  }

  /**
   * Tests executeOperation dispatches pre-execute event.
   *
   * @covers ::executeOperation
   */
  public function testExecuteOperationDispatchesPreExecuteEvent(): void {
    $executionResult = new ExecutionResult(TRUE);

    $mockOperation = $this->createMock(OperationInterface::class);
    $mockOperation->method('execute')->willReturn($executionResult);

    $preEventDispatched = FALSE;

    $this->mockEventDispatcher
      ->method('dispatch')
      ->willReturnCallback(function ($event, $eventName) use (&$preEventDispatched) {
        if ($eventName === OperationEvents::PRE_EXECUTE) {
          $preEventDispatched = TRUE;
          $this->assertInstanceOf(OperationPreExecuteEvent::class, $event);
        }
        return $event;
      });

    $this->operationProcessor->executeOperation($mockOperation);

    $this->assertTrue($preEventDispatched);
  }

  /**
   * Tests executeOperation dispatches post-execute event.
   *
   * @covers ::executeOperation
   */
  public function testExecuteOperationDispatchesPostExecuteEvent(): void {
    $executionResult = new ExecutionResult(TRUE);

    $mockOperation = $this->createMock(OperationInterface::class);
    $mockOperation->method('execute')->willReturn($executionResult);

    $postEventDispatched = FALSE;

    $this->mockEventDispatcher
      ->method('dispatch')
      ->willReturnCallback(function ($event, $eventName) use (&$postEventDispatched) {
        if ($eventName === OperationEvents::POST_EXECUTE) {
          $postEventDispatched = TRUE;
          $this->assertInstanceOf(OperationPostExecuteEvent::class, $event);
        }
        return $event;
      });

    $this->operationProcessor->executeOperation($mockOperation);

    $this->assertTrue($postEventDispatched);
  }

  /**
   * Tests executeOperation respects cancellation from pre-execute event.
   *
   * @covers ::executeOperation
   */
  public function testExecuteOperationCancelledByPreExecuteEvent(): void {
    $mockOperation = $this->createMock(OperationInterface::class);

    // Operation should NOT be executed.
    $mockOperation->expects($this->never())->method('execute');

    $this->mockEventDispatcher
      ->method('dispatch')
      ->willReturnCallback(function ($event, $eventName) {
        if ($event instanceof OperationPreExecuteEvent) {
          $event->cancel('Operation cancelled by subscriber');
        }
        return $event;
      });

    // Rollback should NOT be stored.
    $this->mockRollbackManager->expects($this->never())->method('storeRollbackData');

    $result = $this->operationProcessor->executeOperation($mockOperation);

    $this->assertFalse($result->isSuccess());
    $errors = $result->getErrors();
    $this->assertNotEmpty($errors);
  }

  /**
   * Tests executeOperation handles execution exceptions.
   *
   * @covers ::executeOperation
   */
  public function testExecuteOperationHandlesExceptions(): void {
    $mockOperation = $this->createMock(OperationInterface::class);
    $mockOperation->method('execute')
      ->willThrowException(new \Exception('Database error'));

    $this->mockEventDispatcher
      ->method('dispatch')
      ->willReturnArgument(0);

    // Logger should record error.
    $this->mockLogger
      ->expects($this->atLeastOnce())
      ->method('error');

    $result = $this->operationProcessor->executeOperation($mockOperation);

    $this->assertFalse($result->isSuccess());
    $errors = $result->getErrors();
    $this->assertNotEmpty($errors);
  }

  /**
   * Tests executeOperation stores rollback data on success.
   *
   * @covers ::executeOperation
   */
  public function testExecuteOperationStoresRollbackData(): void {
    $rollbackData = [
      'bundle_entity_type' => 'node_type',
      'bundle_id' => 'article',
      'entity_type' => 'node',
    ];

    $executionResult = new ExecutionResult(TRUE);
    $executionResult->setRollbackData($rollbackData);

    $mockOperation = $this->createMock(OperationInterface::class);
    $mockOperation->method('execute')->willReturn($executionResult);

    $this->mockEventDispatcher->method('dispatch')->willReturnArgument(0);

    $mockRollbackOperation = $this->createMock(EbRollbackOperationInterface::class);
    $this->mockRollbackManager
      ->expects($this->once())
      ->method('storeRollbackData')
      ->with($mockOperation, $executionResult, NULL)
      ->willReturn($mockRollbackOperation);

    $this->operationProcessor->executeOperation($mockOperation);
  }

  /**
   * Tests executeOperation passes definition_id to rollback manager.
   *
   * @covers ::executeOperation
   */
  public function testExecuteOperationPassesDefinitionIdToRollback(): void {
    $executionResult = new ExecutionResult(TRUE);
    $executionResult->setRollbackData(['test' => 'data']);

    $mockOperation = $this->createMock(OperationInterface::class);
    $mockOperation->method('execute')->willReturn($executionResult);

    $this->mockEventDispatcher->method('dispatch')->willReturnArgument(0);

    $definitionId = 'my_definition_123';

    $mockRollbackOperation = $this->createMock(EbRollbackOperationInterface::class);
    $this->mockRollbackManager
      ->expects($this->once())
      ->method('storeRollbackData')
      ->with($mockOperation, $executionResult, $definitionId)
      ->willReturn($mockRollbackOperation);

    $this->operationProcessor->executeOperation($mockOperation, [
      'definition_id' => $definitionId,
    ]);
  }

  /**
   * Tests executeOperation skips rollback storage when no rollback data.
   *
   * @covers ::executeOperation
   */
  public function testExecuteOperationSkipsRollbackWhenNoData(): void {
    $executionResult = new ExecutionResult(TRUE);
    // No rollback data set.
    $mockOperation = $this->createMock(OperationInterface::class);
    $mockOperation->method('execute')->willReturn($executionResult);

    $this->mockEventDispatcher->method('dispatch')->willReturnArgument(0);

    // Rollback storage should NOT be called.
    $this->mockRollbackManager
      ->expects($this->never())
      ->method('storeRollbackData');

    $this->operationProcessor->executeOperation($mockOperation);
  }

  /**
   * Tests executeOperation handles rollback storage failure gracefully.
   *
   * @covers ::executeOperation
   */
  public function testExecuteOperationHandlesRollbackStorageFailure(): void {
    $executionResult = new ExecutionResult(TRUE);
    $executionResult->setRollbackData(['test' => 'data']);

    $mockOperation = $this->createMock(OperationInterface::class);
    $mockOperation->method('execute')->willReturn($executionResult);

    $this->mockEventDispatcher->method('dispatch')->willReturnArgument(0);

    // Rollback storage fails.
    $this->mockRollbackManager
      ->method('storeRollbackData')
      ->willThrowException(new \Exception('Storage failed'));

    // Logger should record the warning (not error - operation succeeded).
    $this->mockLogger
      ->expects($this->atLeastOnce())
      ->method('warning');

    // But operation should still be marked successful.
    $result = $this->operationProcessor->executeOperation($mockOperation);
    $this->assertTrue($result->isSuccess());
  }

  /**
   * Tests executeBatch executes multiple operations.
   *
   * @covers ::executeBatch
   */
  public function testExecuteBatchExecutesMultipleOperations(): void {
    $result1 = new ExecutionResult(TRUE);
    $result2 = new ExecutionResult(TRUE);

    $mockOp1 = $this->createMock(OperationInterface::class);
    $mockOp1->expects($this->once())->method('execute')->willReturn($result1);

    $mockOp2 = $this->createMock(OperationInterface::class);
    $mockOp2->expects($this->once())->method('execute')->willReturn($result2);

    $this->mockEventDispatcher->method('dispatch')->willReturnArgument(0);

    $results = $this->operationProcessor->executeBatch([$mockOp1, $mockOp2]);

    $this->assertCount(2, $results);
    $this->assertTrue($results[0]->isSuccess());
    $this->assertTrue($results[1]->isSuccess());
  }

  /**
   * Tests executeBatch stops on failure when stop_on_failure is true.
   *
   * @covers ::executeBatch
   */
  public function testExecuteBatchStopsOnFailure(): void {
    $result1 = new ExecutionResult(TRUE);
    $result2 = new ExecutionResult(FALSE);
    $result2->addError('Operation failed');

    $mockOp1 = $this->createMock(OperationInterface::class);
    $mockOp1->expects($this->once())->method('execute')->willReturn($result1);

    $mockOp2 = $this->createMock(OperationInterface::class);
    $mockOp2->expects($this->once())->method('execute')->willReturn($result2);

    $mockOp3 = $this->createMock(OperationInterface::class);
    // Op3 should NOT be executed because op2 failed.
    $mockOp3->expects($this->never())->method('execute');

    $this->mockEventDispatcher->method('dispatch')->willReturnArgument(0);

    $results = $this->operationProcessor->executeBatch(
      [$mockOp1, $mockOp2, $mockOp3],
      TRUE
    );

    $this->assertCount(2, $results);
    $this->assertTrue($results[0]->isSuccess());
    $this->assertFalse($results[1]->isSuccess());
    $this->assertArrayNotHasKey(2, $results);
  }

  /**
   * Tests executeBatch continues on failure when stop_on_failure is false.
   *
   * @covers ::executeBatch
   */
  public function testExecuteBatchContinuesOnFailure(): void {
    $result1 = new ExecutionResult(TRUE);
    $result2 = new ExecutionResult(FALSE);
    $result2->addError('Operation failed');
    $result3 = new ExecutionResult(TRUE);

    $mockOp1 = $this->createMock(OperationInterface::class);
    $mockOp1->expects($this->once())->method('execute')->willReturn($result1);

    $mockOp2 = $this->createMock(OperationInterface::class);
    $mockOp2->expects($this->once())->method('execute')->willReturn($result2);

    $mockOp3 = $this->createMock(OperationInterface::class);
    // Op3 SHOULD be executed even though op2 failed.
    $mockOp3->expects($this->once())->method('execute')->willReturn($result3);

    $this->mockEventDispatcher->method('dispatch')->willReturnArgument(0);

    $results = $this->operationProcessor->executeBatch(
      [$mockOp1, $mockOp2, $mockOp3],
      FALSE
    );

    $this->assertCount(3, $results);
    $this->assertTrue($results[0]->isSuccess());
    $this->assertFalse($results[1]->isSuccess());
    $this->assertTrue($results[2]->isSuccess());
  }

  /**
   * Tests executeBatch with empty array.
   *
   * @covers ::executeBatch
   */
  public function testExecuteBatchWithEmptyArray(): void {
    $results = $this->operationProcessor->executeBatch([]);

    $this->assertEmpty($results);
  }

  /**
   * Tests executeBatch passes context to each operation.
   *
   * @covers ::executeBatch
   */
  public function testExecuteBatchPassesContext(): void {
    $result = new ExecutionResult(TRUE);
    $result->setRollbackData(['test' => 'data']);

    $mockOp = $this->createMock(OperationInterface::class);
    $mockOp->method('execute')->willReturn($result);

    $this->mockEventDispatcher->method('dispatch')->willReturnArgument(0);

    $context = ['definition_id' => 'test_def'];

    $this->mockRollbackManager
      ->expects($this->once())
      ->method('storeRollbackData')
      ->with($mockOp, $result, 'test_def');

    $this->operationProcessor->executeBatch([$mockOp], TRUE, $context);
  }

  /**
   * Tests executeBatch preserves operation indices in results.
   *
   * @covers ::executeBatch
   */
  public function testExecuteBatchPreservesIndices(): void {
    $results = [
      new ExecutionResult(TRUE),
      new ExecutionResult(TRUE),
      new ExecutionResult(TRUE),
    ];

    $operations = [];
    foreach ($results as $result) {
      $mockOp = $this->createMock(OperationInterface::class);
      $mockOp->method('execute')->willReturn($result);
      $operations[] = $mockOp;
    }

    $this->mockEventDispatcher->method('dispatch')->willReturnArgument(0);

    $batchResults = $this->operationProcessor->executeBatch($operations);

    $this->assertArrayHasKey(0, $batchResults);
    $this->assertArrayHasKey(1, $batchResults);
    $this->assertArrayHasKey(2, $batchResults);
  }

}
