<?php

declare(strict_types=1);

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

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\flowdrop\Constants\ReservedName;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\FlowDropNodeProcessorInterface;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
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 output-port exposure enforcement at the node-execution chokepoint.
 *
 * In v2 exposure is semantic: a not-exposed output port's data must not leave
 * the node. filterUnexposedOutputs() runs at the production point so the value
 * is never serialized into the unified output, job records, checkpoints,
 * real-time updates, or a tool result handed back to a model. Effective
 * exposure is the instance's config.ports override, else the port's metadata
 * x-exposed-by-default — read through PortExposure, mirroring the input side.
 *
 * @coversDefaultClass \Drupal\flowdrop_runtime\Service\Runtime\NodeRuntimeService
 * @group flowdrop_runtime
 */
class NodeRuntimeServiceOutputExposureTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * Invokes the protected filterUnexposedOutputs() with a prepared fixture.
   *
   * @param array<string, mixed> $result
   *   The processor's raw output.
   * @param array<string, mixed> $outputSchema
   *   The processor's getOutputSchema() return.
   * @param array<string, mixed> $workflowValues
   *   The node instance config (carries the reserved `ports` key).
   *
   * @return array<string, mixed>
   *   The filtered output.
   */
  private function filterWith(array $result, array $outputSchema, array $workflowValues): array {
    $processor = $this->prophesize(FlowDropNodeProcessorInterface::class);
    $processor->getOutputSchema()->willReturn($outputSchema);

    $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, 'filterUnexposedOutputs');
    return $method->invoke($service, $result, $processor->reveal(), $workflowValues);
  }

  /**
   * Output schema declaring two ports exposed by default.
   *
   * @return array<string, mixed>
   *   The schema.
   */
  private function twoPortSchema(): array {
    return [
      'properties' => [
        'result' => ['type' => 'string'],
        'ssn' => ['type' => 'string'],
      ],
    ];
  }

  /**
   * Builds a config.ports value flipping one output port's exposure.
   *
   * @param string $portId
   *   The output port id.
   * @param bool $exposed
   *   The exposure flag to store.
   *
   * @return array<string, mixed>
   *   The workflow values carrying the override.
   */
  private function portsOverride(string $portId, bool $exposed): array {
    return [
      ReservedName::PORTS => [
        'outputs' => [
          ['id' => $portId, 'exposed' => $exposed],
        ],
      ],
    ];
  }

  /**
   * An untouched node (no overrides, default-exposed ports) strips nothing.
   *
   * @covers ::filterUnexposedOutputs
   */
  public function testDefaultExposureKeepsEverything(): void {
    $result = ['result' => 'ok', 'ssn' => '123-45-6789'];
    $filtered = $this->filterWith($result, $this->twoPortSchema(), []);
    $this->assertSame($result, $filtered);
  }

  /**
   * An output port un-exposed via config.ports has its value stripped.
   *
   * @covers ::filterUnexposedOutputs
   */
  public function testUnexposedPortValueIsStripped(): void {
    $result = ['result' => 'ok', 'ssn' => '123-45-6789'];
    $filtered = $this->filterWith(
      $result,
      $this->twoPortSchema(),
      $this->portsOverride('ssn', FALSE),
    );
    $this->assertSame(['result' => 'ok'], $filtered);
  }

  /**
   * A port whose metadata ships not-exposed is stripped without an override.
   *
   * @covers ::filterUnexposedOutputs
   */
  public function testDefaultNotExposedPortIsStripped(): void {
    $schema = [
      'properties' => [
        'result' => ['type' => 'string'],
        'debug' => ['type' => 'string', 'x-exposed-by-default' => FALSE],
      ],
    ];
    $result = ['result' => 'ok', 'debug' => 'internal trace'];
    $filtered = $this->filterWith($result, $schema, []);
    $this->assertSame(['result' => 'ok'], $filtered);
  }

  /**
   * An instance override re-exposing a default-not-exposed port keeps it.
   *
   * @covers ::filterUnexposedOutputs
   */
  public function testOverrideReExposesDefaultNotExposedPort(): void {
    $schema = [
      'properties' => [
        'debug' => ['type' => 'string', 'x-exposed-by-default' => FALSE],
      ],
    ];
    $result = ['debug' => 'now wanted'];
    $filtered = $this->filterWith($result, $schema, $this->portsOverride('debug', TRUE));
    $this->assertSame(['debug' => 'now wanted'], $filtered);
  }

  /**
   * Keys not declared as output ports pass through (deny-list semantics).
   *
   * @covers ::filterUnexposedOutputs
   */
  public function testUndeclaredKeysPassThrough(): void {
    $result = ['result' => 'ok', 'dynamic_field' => 'kept', 'ssn' => 'secret'];
    $filtered = $this->filterWith(
      $result,
      $this->twoPortSchema(),
      $this->portsOverride('ssn', FALSE),
    );
    // 'dynamic_field' is not in the output schema, so exposure does not govern
    // it; only the declared, un-exposed 'ssn' is removed.
    $this->assertSame(['result' => 'ok', 'dynamic_field' => 'kept'], $filtered);
  }

}
