<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop\Unit\Service;

use Prophecy\Argument;
use Drupal\Core\Form\FormStateInterface;
use Drupal\flowdrop\Service\FlowDropSchemaFormBuilder;
use Drupal\Tests\UnitTestCase;
use Prophecy\PhpUnit\ProphecyTrait;

/**
 * Tests the FlowDropSchemaFormBuilder service.
 *
 * Behavioural coverage for the schema → Drupal form bridge: element
 * synthesis, value extraction round-trips, validation. Special attention to
 * the pattern-compilation path that guards against ReDoS / malformed input.
 *
 * @coversDefaultClass \Drupal\flowdrop\Service\FlowDropSchemaFormBuilder
 * @group flowdrop
 */
class FlowDropSchemaFormBuilderTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The builder under test.
   */
  private FlowDropSchemaFormBuilder $builder;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->builder = new FlowDropSchemaFormBuilder();
    // String translation: minimum viable so $this->t() in the builder works.
    $translation = $this->getStringTranslationStub();
    $reflection = new \ReflectionProperty($this->builder, 'stringTranslation');
    $reflection->setAccessible(TRUE);
    $reflection->setValue($this->builder, $translation);
  }

  /**
   * String schema renders as a textfield by default.
   */
  public function testStringElementDefaultsToTextfield(): void {
    $element = $this->builder->buildElement(['type' => 'string']);
    $this->assertSame('textfield', $element['#type']);
  }

  /**
   * String format "multiline" picks textarea.
   */
  public function testMultilineFormatPicksTextarea(): void {
    $element = $this->builder->buildElement([
      'type' => 'string',
      'format' => 'multiline',
    ]);
    $this->assertSame('textarea', $element['#type']);
  }

  /**
   * Boolean schema renders as checkbox.
   */
  public function testBooleanElementIsCheckbox(): void {
    $element = $this->builder->buildElement(['type' => 'boolean']);
    $this->assertSame('checkbox', $element['#type']);
  }

  /**
   * Enum with ≤10 values renders as radios.
   */
  public function testShortEnumRendersAsRadios(): void {
    $element = $this->builder->buildElement([
      'type' => 'string',
      'enum' => ['a', 'b', 'c'],
    ]);
    $this->assertSame('radios', $element['#type']);
    $this->assertSame(['a' => 'a', 'b' => 'b', 'c' => 'c'], $element['#options']);
  }

  /**
   * Enum with >10 values renders as select with empty option.
   */
  public function testLongEnumRendersAsSelect(): void {
    $element = $this->builder->buildElement([
      'type' => 'string',
      'enum' => range(1, 12),
    ]);
    $this->assertSame('select', $element['#type']);
    $this->assertNotNull($element['#empty_option']);
  }

  /**
   * Array of enum strings → checkboxes.
   */
  public function testArrayOfEnumRendersAsCheckboxes(): void {
    $element = $this->builder->buildElement([
      'type' => 'array',
      'items' => ['type' => 'string', 'enum' => ['x', 'y']],
    ]);
    $this->assertSame('checkboxes', $element['#type']);
  }

  /**
   * Object with properties → fieldset with #tree.
   */
  public function testObjectWithPropertiesRendersAsFieldset(): void {
    $element = $this->builder->buildElement([
      'type' => 'object',
      'properties' => [
        'name' => ['type' => 'string'],
        'age' => ['type' => 'integer'],
      ],
    ]);
    $this->assertSame('fieldset', $element['#type']);
    $this->assertTrue($element['#tree']);
    $this->assertArrayHasKey('name', $element);
    $this->assertArrayHasKey('age', $element);
  }

  /**
   * Schema's own `default` is used when no override supplied.
   */
  public function testSchemaDefaultIsHonoured(): void {
    $element = $this->builder->buildElement([
      'type' => 'string',
      'default' => 'preset',
    ]);
    $this->assertSame('preset', $element['#default_value']);
  }

  /**
   * Explicit defaultValue overrides the schema's default.
   */
  public function testExplicitDefaultWinsOverSchemaDefault(): void {
    $element = $this->builder->buildElement(
      ['type' => 'string', 'default' => 'preset'],
      'override',
    );
    $this->assertSame('override', $element['#default_value']);
  }

  /**
   * Integer extracts to (int).
   */
  public function testExtractValueIntegerCoercion(): void {
    $this->assertSame(42, $this->builder->extractValue(['type' => 'integer'], '42'));
    $this->assertNull($this->builder->extractValue(['type' => 'integer'], ''));
  }

  /**
   * Number extracts to (float).
   */
  public function testExtractValueNumberCoercion(): void {
    $this->assertSame(1.5, $this->builder->extractValue(['type' => 'number'], '1.5'));
  }

  /**
   * Boolean extracts to (bool).
   */
  public function testExtractValueBooleanCoercion(): void {
    $this->assertTrue($this->builder->extractValue(['type' => 'boolean'], TRUE));
    $this->assertTrue($this->builder->extractValue(['type' => 'boolean'], 1));
    $this->assertFalse($this->builder->extractValue(['type' => 'boolean'], 0));
  }

  /**
   * Object with properties extracts recursively per property.
   */
  public function testExtractValueObjectRecursesIntoProperties(): void {
    $schema = [
      'type' => 'object',
      'properties' => [
        'name' => ['type' => 'string'],
        'count' => ['type' => 'integer'],
      ],
    ];
    $result = $this->builder->extractValue($schema, [
      'name' => 'alice',
      'count' => '7',
    ]);
    $this->assertSame(['name' => 'alice', 'count' => 7], $result);
  }

  /**
   * Free-form object extracts JSON string.
   */
  public function testExtractValueFreeFormObjectParsesJson(): void {
    $result = $this->builder->extractValue(
      ['type' => 'object'],
      '{"foo": "bar"}',
    );
    $this->assertSame(['foo' => 'bar'], $result);
  }

  /**
   * Array of enum filters out the unchecked boxes (value 0).
   */
  public function testExtractValueCheckboxesFiltersUnchecked(): void {
    $schema = ['type' => 'array', 'items' => ['enum' => ['a', 'b']]];
    $this->assertSame(['a'], $this->builder->extractValue($schema, ['a' => 'a', 'b' => 0]));
  }

  /**
   * Pattern compilation rejects patterns longer than the cap.
   *
   * Long attacker-controlled patterns are treated as opaque (no validation)
   * to avoid ReDoS — the value passes validation regardless.
   */
  public function testLongPatternIsTreatedAsOpaque(): void {
    $schema = [
      'type' => 'string',
      'pattern' => str_repeat('a', 600),
    ];
    $formState = $this->prophesize(FormStateInterface::class);
    $formState->setErrorByName(Argument::cetera())->shouldNotBeCalled();
    $valid = $this->builder->validateValue($schema, 'anything', $formState->reveal(), ['x']);
    $this->assertTrue($valid);
  }

  /**
   * Pattern with unescaped `#` is rejected as opaque (no validation runs).
   */
  public function testPatternWithUnescapedDelimiterIsOpaque(): void {
    $schema = [
      'type' => 'string',
      'pattern' => 'foo#bar',
    ];
    $formState = $this->prophesize(FormStateInterface::class);
    $formState->setErrorByName(Argument::cetera())->shouldNotBeCalled();
    $valid = $this->builder->validateValue($schema, 'whatever', $formState->reveal(), ['x']);
    $this->assertTrue($valid);
  }

  /**
   * Valid pattern is enforced — non-matching value triggers a form error.
   */
  public function testValidPatternEnforces(): void {
    $schema = [
      'type' => 'string',
      'pattern' => '^[a-z]+$',
    ];
    $formState = $this->prophesize(FormStateInterface::class);
    $formState->setErrorByName('x', Argument::any())->shouldBeCalled();
    $valid = $this->builder->validateValue($schema, '123', $formState->reveal(), ['x']);
    $this->assertFalse($valid);
  }

  /**
   * MinLength / maxLength enforce on strings.
   */
  public function testStringLengthEnforced(): void {
    $schema = ['type' => 'string', 'minLength' => 3, 'maxLength' => 5];

    $tooShort = $this->prophesize(FormStateInterface::class);
    $tooShort->setErrorByName('x', Argument::any())->shouldBeCalled();
    $this->assertFalse($this->builder->validateValue($schema, 'ab', $tooShort->reveal(), ['x']));

    $tooLong = $this->prophesize(FormStateInterface::class);
    $tooLong->setErrorByName('x', Argument::any())->shouldBeCalled();
    $this->assertFalse($this->builder->validateValue($schema, 'abcdef', $tooLong->reveal(), ['x']));

    $ok = $this->prophesize(FormStateInterface::class);
    $ok->setErrorByName(Argument::cetera())->shouldNotBeCalled();
    $this->assertTrue($this->builder->validateValue($schema, 'abcd', $ok->reveal(), ['x']));
  }

  /**
   * Enum membership is enforced.
   */
  public function testEnumMembershipEnforced(): void {
    $schema = ['type' => 'string', 'enum' => ['a', 'b']];

    $bad = $this->prophesize(FormStateInterface::class);
    $bad->setErrorByName('x', Argument::any())->shouldBeCalled();
    $this->assertFalse($this->builder->validateValue($schema, 'c', $bad->reveal(), ['x']));

    $ok = $this->prophesize(FormStateInterface::class);
    $ok->setErrorByName(Argument::cetera())->shouldNotBeCalled();
    $this->assertTrue($this->builder->validateValue($schema, 'a', $ok->reveal(), ['x']));
  }

  /**
   * Deeply nested objects trip the recursion guard and degrade to a notice.
   */
  public function testDeepRecursionDegradesGracefully(): void {
    // Build a 14-level nested object schema (cap is 12).
    $deep = ['type' => 'string'];
    for ($i = 0; $i < 14; $i++) {
      $deep = ['type' => 'object', 'properties' => ['nested' => $deep]];
    }
    $element = $this->builder->buildElement($deep);
    // We expect the deeply nested level to render the fallback.
    // Walk into the structure to find the degraded notice.
    $walker = $element;
    while (isset($walker['nested'])) {
      $walker = $walker['nested'];
    }
    $this->assertSame('item', $walker['#type']);
    $this->assertStringContainsString('deeply nested', (string) $walker['#markup']);
  }

}
