<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_node_processor\Unit\Plugin\FlowDropNodeProcessor;

use Drupal\Component\Serialization\Json;
use Drupal\flowdrop\DTO\ParameterBag;
use Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\MergeNode;
use PHPUnit\Framework\TestCase;

/**
 * Tests the Merge node processor.
 *
 * @coversDefaultClass \Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\MergeNode
 * @group flowdrop_node_processor
 */
class MergeNodeTest extends TestCase {

  /**
   * Creates a Merge processor.
   *
   * @return \Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\MergeNode
   *   The processor instance.
   */
  private function createProcessor(): MergeNode {
    return new MergeNode([], "merge", ["id" => "merge"]);
  }

  /**
   * Tests array mode produces an ordered list following port declaration order.
   *
   * @covers ::process
   */
  public function testArrayModeOrderedByPortDeclaration(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "output_mode" => "array",
      "dynamicInputs" => [
        ["name" => "in_1", "label" => "First", "dataType" => "string"],
        ["name" => "in_2", "label" => "Second", "dataType" => "string"],
        ["name" => "in_3", "label" => "Third", "dataType" => "string"],
      ],
      "in_1" => "a",
      "in_2" => "b",
      "in_3" => "c",
    ]);

    $result = $processor->process($params);

    $this->assertSame(["a", "b", "c"], $result["merged"]);
    $this->assertTrue(array_is_list($result["merged"]));
  }

  /**
   * Tests array mode defaults when no output_mode is supplied.
   *
   * @covers ::process
   */
  public function testArrayModeIsDefault(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "dynamicInputs" => [
        ["name" => "in_1", "label" => "First", "dataType" => "string"],
      ],
      "in_1" => "only",
    ]);

    $result = $processor->process($params);

    $this->assertSame(["only"], $result["merged"]);
  }

  /**
   * Tests an unconnected middle port keeps a NULL placeholder, staying a list.
   *
   * @covers ::process
   */
  public function testArrayModeUnconnectedPortKeepsNullPlaceholder(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "output_mode" => "array",
      "dynamicInputs" => [
        ["name" => "in_1", "label" => "First", "dataType" => "string"],
        ["name" => "in_2", "label" => "Second", "dataType" => "string"],
        ["name" => "in_3", "label" => "Third", "dataType" => "string"],
      ],
      "in_1" => "a",
      // in_2 is unconnected — no value supplied.
      "in_3" => "c",
    ]);

    $result = $processor->process($params);

    $this->assertSame(["a", NULL, "c"], $result["merged"]);
    $this->assertTrue(array_is_list($result["merged"]));
  }

  /**
   * Tests object mode produces a name-keyed map.
   *
   * @covers ::process
   */
  public function testObjectModeKeyedByPortName(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "output_mode" => "object",
      "dynamicInputs" => [
        ["name" => "first_name", "label" => "First", "dataType" => "string"],
        ["name" => "last_name", "label" => "Last", "dataType" => "string"],
      ],
      "first_name" => "Alice",
      "last_name" => "Smith",
    ]);

    $result = $processor->process($params);

    $this->assertSame(
      ["first_name" => "Alice", "last_name" => "Smith"],
      $result["merged"]
    );
  }

  /**
   * Tests empty inputs yield an empty list in array mode.
   *
   * @covers ::process
   */
  public function testArrayModeNoPortsYieldsEmptyList(): void {
    $processor = $this->createProcessor();

    $result = $processor->process(new ParameterBag(["output_mode" => "array"]));

    $this->assertSame([], $result["merged"]);
  }

  /**
   * Tests empty inputs yield an empty map in object mode.
   *
   * @covers ::process
   */
  public function testObjectModeNoPortsYieldsEmptyMap(): void {
    $processor = $this->createProcessor();

    $result = $processor->process(new ParameterBag(["output_mode" => "object"]));

    $this->assertSame([], $result["merged"]);
  }

  /**
   * Tests the array-mode invariant: output serializes to a JSON array.
   *
   * Pins the array_values() guarantee against regression. A gapped/keyed PHP
   * array would serialize to a JSON object ({...}); array mode must always
   * produce a JSON array ([...]) regardless of which ports are connected.
   *
   * @covers ::process
   */
  public function testArrayModeSerializesToJsonArray(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "output_mode" => "array",
      "dynamicInputs" => [
        ["name" => "in_1", "label" => "First", "dataType" => "string"],
        ["name" => "in_2", "label" => "Second", "dataType" => "string"],
        ["name" => "in_3", "label" => "Third", "dataType" => "string"],
      ],
      // Only the last port connected — would gap the keys without array_values.
      "in_3" => "c",
    ]);

    $result = $processor->process($params);
    $encoded = Json::encode($result["merged"]);

    $this->assertStringStartsWith("[", $encoded);
    $this->assertSame([NULL, NULL, "c"], Json::decode($encoded));
  }

  /**
   * Tests non-array port definitions are safely skipped.
   *
   * @covers ::process
   */
  public function testMalformedDefinitionsSkipped(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "output_mode" => "array",
      "dynamicInputs" => [
        "not_an_array",
        ["name" => "in_1", "label" => "First", "dataType" => "string"],
      ],
      "in_1" => "value",
    ]);

    $result = $processor->process($params);

    $this->assertSame(["value"], $result["merged"]);
  }

  /**
   * Tests reserved port names are rejected (delegated to DynamicPortTrait).
   *
   * @covers ::process
   */
  public function testReservedNameRejected(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "dynamicInputs" => [
        ["name" => "trigger", "label" => "Trigger", "dataType" => "string"],
      ],
    ]);

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage("reserved");
    $processor->process($params);
  }

  /**
   * Tests the parameter schema exposes output_mode and dynamicInputs only.
   *
   * @covers ::getParameterSchema
   */
  public function testParameterSchema(): void {
    $processor = $this->createProcessor();
    $schema = $processor->getParameterSchema();

    $this->assertEquals("object", $schema["type"]);
    $this->assertArrayHasKey("output_mode", $schema["properties"]);
    $this->assertArrayHasKey("dynamicInputs", $schema["properties"]);
    // The Merge node has a single static output — no dynamic outputs.
    $this->assertArrayNotHasKey("dynamicOutputs", $schema["properties"]);

    $this->assertSame(
      ["array", "object"],
      $schema["properties"]["output_mode"]["enum"]
    );
    $this->assertSame("array", $schema["properties"]["output_mode"]["default"]);
  }

  /**
   * Tests the output schema declares the static "merged" port.
   *
   * @covers ::getOutputSchema
   */
  public function testOutputSchema(): void {
    $processor = $this->createProcessor();
    $schema = $processor->getOutputSchema();

    $this->assertArrayHasKey("merged", $schema["properties"]);
  }

  /**
   * Tests validation always succeeds.
   *
   * @covers ::validateParams
   */
  public function testValidation(): void {
    $processor = $this->createProcessor();

    $this->assertTrue($processor->validateParams([])->isValid());
  }

}
