<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_runtime\Unit\Service\Runtime;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop_runtime\Exception\NonSerializableOutputException;
use Drupal\flowdrop_runtime\Service\Logging\ExecutionLoggerInterface;
use Drupal\flowdrop_runtime\Service\ParameterResolverInterface;
use Drupal\flowdrop_runtime\Service\RealTime\RealTimeManagerInterface;
use Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeService;
use Drupal\Tests\UnitTestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

/**
 * Pins the json_encode-safety guard on node output.
 *
 * Every engine persists output through json_encode, which silently emits an
 * empty object for values it cannot represent (entities, Url, Markup, closures,
 * resources) rather than throwing. The runtime validates the composed output at
 * the single waist all engines share and turns that silent corruption into a
 * loud, node-scoped NonSerializableOutputException. Allowed leaves mirror
 * json_encode's own contract: scalars, null, array, stdClass, JsonSerializable.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeService
 * @group flowdrop_runtime
 */
class NodeRuntimeServiceSerializableOutputTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * Invokes the protected assertSerializableOutput() with a prepared fixture.
   *
   * @param array<string, mixed> $output
   *   The composed node output to validate.
   */
  private function assertOutput(array $output): void {
    $service = new NodeRuntimeService(
      $this->prophesize(ExecutionLoggerInterface::class)->reveal(),
      $this->prophesize(EventDispatcherInterface::class)->reveal(),
      $this->prophesize(FlowDropNodeProcessorPluginManager::class)->reveal(),
      $this->prophesize(RealTimeManagerInterface::class)->reveal(),
      $this->prophesize(ParameterResolverInterface::class)->reveal(),
      $this->prophesize(EntityTypeManagerInterface::class)->reveal(),
    );

    $method = new \ReflectionMethod(NodeRuntimeService::class, 'assertSerializableOutput');
    $method->invoke($service, $output, 'my_node', 'my_plugin');
  }

  /**
   * Scalars, null, and nested arrays are all json-safe and pass untouched.
   *
   * @covers ::assertSerializableOutput
   */
  public function testJsonSafeLeavesPass(): void {
    $this->expectNotToPerformAssertions();
    $this->assertOutput([
      'string' => 'ok',
      'int' => 1,
      'float' => 1.5,
      'bool' => TRUE,
      'null' => NULL,
      'nested' => ['a' => [1, 2, ['b' => 'deep']]],
    ]);
  }

  /**
   * A stdClass and its scalar properties are json-safe and pass.
   *
   * @covers ::assertSerializableOutput
   */
  public function testStdClassPasses(): void {
    $this->expectNotToPerformAssertions();
    $obj = new \stdClass();
    $obj->a = 'x';
    $obj->b = [1, 2];
    $this->assertOutput(['payload' => $obj]);
  }

  /**
   * An object opting into json_encode via \JsonSerializable passes.
   *
   * @covers ::assertSerializableOutput
   */
  public function testJsonSerializablePasses(): void {
    $this->expectNotToPerformAssertions();
    $serializable = new class implements \JsonSerializable {

      /**
       * {@inheritdoc}
       */
      public function jsonSerialize(): array {
        return ['ok' => TRUE];
      }

    };
    $this->assertOutput(['payload' => $serializable]);
  }

  /**
   * A plain object (entity-like) json_encode would mangle is rejected.
   *
   * @covers ::assertSerializableOutput
   */
  public function testPlainObjectIsRejected(): void {
    $entityLike = new class() {};
    $this->expectException(NonSerializableOutputException::class);
    $this->assertOutput(['node' => $entityLike]);
  }

  /**
   * The error names the offending node, plugin, path, and value type.
   *
   * @covers ::assertSerializableOutput
   */
  public function testErrorMessageLocatesTheCulprit(): void {
    $entityLike = new class() {};
    $class = $entityLike::class;
    try {
      $this->assertOutput(['data' => ['items' => [0 => $entityLike]]]);
      $this->fail('Expected NonSerializableOutputException was not thrown.');
    }
    catch (NonSerializableOutputException $e) {
      $this->assertStringContainsString('my_node', $e->getMessage());
      $this->assertStringContainsString('my_plugin', $e->getMessage());
      // Dotted path pinpoints the leaf inside nested arrays.
      $this->assertStringContainsString('output.data.items.0', $e->getMessage());
      $this->assertStringContainsString($class, $e->getMessage());
    }
  }

  /**
   * An unsafe object nested inside a stdClass property is still caught.
   *
   * @covers ::assertSerializableOutput
   */
  public function testUnsafeObjectNestedInStdClassIsRejected(): void {
    $wrapper = new \stdClass();
    $wrapper->entity = new class() {};
    $this->expectException(NonSerializableOutputException::class);
    $this->assertOutput(['payload' => $wrapper]);
  }

  /**
   * A closure — an object json_encode drops — is rejected.
   *
   * @covers ::assertSerializableOutput
   */
  public function testClosureIsRejected(): void {
    $this->expectException(NonSerializableOutputException::class);
    $this->assertOutput(['cb' => fn () => TRUE]);
  }

}
