<?php

declare(strict_types=1);

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

use Drupal\flowdrop\DTO\ParameterBag;
use Drupal\flowdrop\Plugin\ExpressionEvaluator\ExpressionEvaluatorInterface;
use Drupal\flowdrop\Service\ExpressionEvaluatorPluginManager;
use Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\DataMapper;
use PHPUnit\Framework\TestCase;

/**
 * Tests the DataMapper node processor.
 *
 * @coversDefaultClass \Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\DataMapper
 * @group flowdrop_node_processor
 */
class DataMapperTest extends TestCase {

  /**
   * Creates a DataMapper processor with a mock evaluator manager.
   *
   * The mock evaluator performs simple variable substitution for testing:
   * expressions like "first_name ~ \" \" ~ last_name" are simulated by
   * looking up variable names in the context.
   *
   * @param callable|null $evaluateCallback
   *   Optional custom evaluate callback. If NULL, uses a simple string
   *   substitution that replaces variable references with context values.
   *
   * @return \Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor\DataMapper
   *   The processor instance.
   */
  private function createProcessor(?callable $evaluateCallback = NULL): DataMapper {
    $evaluator = $this->createMock(ExpressionEvaluatorInterface::class);

    if ($evaluateCallback !== NULL) {
      $evaluator->method("evaluate")->willReturnCallback($evaluateCallback);
    }
    else {
      // Default: simple string substitution replacing {{ var }} with values.
      $evaluator->method("evaluate")->willReturnCallback(
        function (string $expression, mixed $context): mixed {
          if (!is_array($context)) {
            return $expression;
          }
          $result = $expression;
          foreach ($context as $key => $value) {
            if (is_scalar($value) || $value === NULL) {
              $result = str_replace(
                "{{ {$key} }}",
                $value !== NULL ? (string) $value : "",
                $result
              );
            }
          }
          return $result;
        }
      );
    }

    $evaluatorManager = $this->createMock(ExpressionEvaluatorPluginManager::class);
    $evaluatorManager->method("createInstance")->willReturn($evaluator);
    $evaluatorManager->method("getDefinitions")->willReturn([
      "expression_language" => ["label" => "Expression Language"],
      "twig" => ["label" => "Twig"],
      "jsonpath" => ["label" => "JSONPath"],
      "property_path" => ["label" => "Property Path"],
    ]);

    return new DataMapper([], "data_mapper", ["id" => "data_mapper"], $evaluatorManager);
  }

  /**
   * Tests simple expression evaluation.
   *
   * @covers ::process
   */
  public function testSimpleExpression(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "dynamicInputs" => [
        ["name" => "first_name", "label" => "First Name", "dataType" => "string"],
        ["name" => "last_name", "label" => "Last Name", "dataType" => "string"],
      ],
      "dynamicOutputs" => [
        [
          "name" => "full_name",
          "label" => "Full Name",
          "dataType" => "string",
          "value" => "{{ first_name }} {{ last_name }}",
        ],
      ],
      "first_name" => "Alice",
      "last_name" => "Smith",
    ]);

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

    $this->assertArrayHasKey("full_name", $result);
    $this->assertEquals("Alice Smith", $result["full_name"]);
  }

  /**
   * Tests multiple output expressions.
   *
   * @covers ::process
   */
  public function testMultipleOutputs(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "dynamicInputs" => [
        ["name" => "name", "label" => "Name", "dataType" => "string"],
        ["name" => "city", "label" => "City", "dataType" => "string"],
      ],
      "dynamicOutputs" => [
        [
          "name" => "greeting",
          "label" => "Greeting",
          "dataType" => "string",
          "value" => "Hello {{ name }}",
        ],
        [
          "name" => "location",
          "label" => "Location",
          "dataType" => "string",
          "value" => "{{ name }} lives in {{ city }}",
        ],
      ],
      "name" => "Bob",
      "city" => "Berlin",
    ]);

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

    $this->assertCount(2, $result);
    $this->assertEquals("Hello Bob", $result["greeting"]);
    $this->assertEquals("Bob lives in Berlin", $result["location"]);
  }

  /**
   * Tests empty expression returns NULL.
   *
   * @covers ::process
   */
  public function testEmptyExpressionReturnsNull(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "dynamicInputs" => [],
      "dynamicOutputs" => [
        ["name" => "empty_output", "label" => "Empty", "dataType" => "string", "value" => ""],
      ],
    ]);

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

    $this->assertArrayHasKey("empty_output", $result);
    $this->assertNull($result["empty_output"]);
  }

  /**
   * Tests with no dynamic ports defined.
   *
   * @covers ::process
   */
  public function testNoDynamicPorts(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([]);

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

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

  /**
   * Tests that output definitions with empty names are skipped.
   *
   * @covers ::process
   */
  public function testEmptyNameSkipped(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "dynamicInputs" => [],
      "dynamicOutputs" => [
        ["name" => "", "label" => "No Name", "value" => "should be skipped"],
        ["name" => "valid", "label" => "Valid", "dataType" => "string", "value" => "ok"],
      ],
    ]);

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

    $this->assertCount(1, $result);
    $this->assertArrayHasKey("valid", $result);
    $this->assertArrayNotHasKey("", $result);
  }

  /**
   * Tests parameterSchema includes dynamicInputs, dynamicOutputs, and engine.
   *
   * @covers ::getParameterSchema
   */
  public function testParameterSchemaIncludesDynamicPortsAndEngine(): void {
    $processor = $this->createProcessor();
    $schema = $processor->getParameterSchema();

    $this->assertEquals("object", $schema["type"]);
    $this->assertArrayHasKey("dynamicInputs", $schema["properties"]);
    $this->assertArrayHasKey("dynamicOutputs", $schema["properties"]);
    $this->assertArrayHasKey("engine", $schema["properties"]);
    $this->assertEquals("array", $schema["properties"]["dynamicInputs"]["type"]);
    $this->assertEquals("array", $schema["properties"]["dynamicOutputs"]["type"]);
    $this->assertEquals("string", $schema["properties"]["engine"]["type"]);
    $this->assertEquals("expression_language", $schema["properties"]["engine"]["default"]);

    // Output items should have a 'value' property for expressions.
    $outputItemProps = $schema["properties"]["dynamicOutputs"]["items"]["properties"];
    $this->assertArrayHasKey("value", $outputItemProps);
    $this->assertEquals("template", $outputItemProps["value"]["format"]);
  }

  /**
   * Tests output schema is empty (all outputs are dynamic).
   *
   * @covers ::getOutputSchema
   */
  public function testOutputSchemaEmpty(): void {
    $processor = $this->createProcessor();
    $schema = $processor->getOutputSchema();

    $this->assertEquals("object", $schema["type"]);
    $this->assertEmpty($schema["properties"]);
  }

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

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

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

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

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

  /**
   * Tests that internal parameter names (__ prefix) are rejected.
   *
   * @covers ::process
   */
  public function testInternalNameRejected(): void {
    $processor = $this->createProcessor();

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

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

  /**
   * Tests that invalid port names are rejected.
   *
   * @covers ::process
   */
  public function testInvalidNameRejected(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "dynamicInputs" => [
        ["name" => "123invalid", "label" => "Bad", "dataType" => "string"],
      ],
      "dynamicOutputs" => [],
    ]);

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage("must start with a letter");
    $processor->process($params);
  }

  /**
   * Tests that duplicate port names across inputs and outputs are rejected.
   *
   * @covers ::process
   */
  public function testDuplicateNameRejected(): void {
    $processor = $this->createProcessor();

    $params = new ParameterBag([
      "dynamicInputs" => [
        ["name" => "data", "label" => "Input Data", "dataType" => "string"],
      ],
      "dynamicOutputs" => [
        ["name" => "data", "label" => "Output Data", "dataType" => "string", "value" => "{{ data }}"],
      ],
    ]);

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

  /**
   * Tests that expression errors propagate as RuntimeException.
   *
   * @covers ::process
   */
  public function testExpressionErrorPropagates(): void {
    $processor = $this->createProcessor(function (string $expression, mixed $context): mixed {
      throw new \RuntimeException("Expression syntax error: Unexpected token");
    });

    $params = new ParameterBag([
      "dynamicInputs" => [],
      "dynamicOutputs" => [
        ["name" => "bad", "label" => "Bad", "dataType" => "string", "value" => "invalid syntax"],
      ],
    ]);

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage("Expression error in dynamic output 'bad'");
    $processor->process($params);
  }

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

    $params = new ParameterBag([
      "dynamicInputs" => [
        "not_an_array",
        ["name" => "valid_input", "label" => "Valid", "dataType" => "string"],
      ],
      "dynamicOutputs" => [
        42,
        ["name" => "valid_output", "label" => "Valid", "dataType" => "string", "value" => "{{ valid_input }}"],
      ],
      "valid_input" => "hello",
    ]);

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

    $this->assertCount(1, $result);
    $this->assertEquals("hello", $result["valid_output"]);
  }

  /**
   * Tests that engine parameter is passed from params.
   *
   * @covers ::process
   */
  public function testEngineSelectionFromParams(): void {
    $evaluator = $this->createMock(ExpressionEvaluatorInterface::class);
    $evaluator->method("evaluate")->willReturn("result");

    $evaluatorManager = $this->createMock(ExpressionEvaluatorPluginManager::class);
    $evaluatorManager->expects($this->once())
      ->method("createInstance")
      ->with("twig")
      ->willReturn($evaluator);
    $evaluatorManager->method("getDefinitions")->willReturn([
      "expression_language" => ["label" => "Expression Language"],
      "twig" => ["label" => "Twig"],
    ]);

    $processor = new DataMapper([], "data_mapper", ["id" => "data_mapper"], $evaluatorManager);

    $params = new ParameterBag([
      "engine" => "twig",
      "dynamicInputs" => [],
      "dynamicOutputs" => [
        ["name" => "out", "label" => "Out", "dataType" => "string", "value" => "{{ test }}"],
      ],
    ]);

    $result = $processor->process($params);
    $this->assertEquals("result", $result["out"]);
  }

}
