<?php

declare(strict_types=1);

namespace Drupal\flowdrop\Service;

use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;

/**
 * Default FlowDropSchemaFormBuilder implementation.
 *
 * Covers the FlowDropSchema contract subset (see
 * `FlowDropSchemaFormBuilderInterface` for the supported-keywords list).
 * Out-of-subset keys (`oneOf`, `additionalProperties`, etc.) are not
 * rendered — they fall through to a JSON textarea as a last-resort widget.
 *
 * @internal
 */
final class FlowDropSchemaFormBuilder implements FlowDropSchemaFormBuilderInterface {

  use StringTranslationTrait;

  /**
   * Maximum recursion depth when rendering nested objects / arrays.
   *
   * Guards against malformed or self-referential schemas without imposing a
   * limit users will hit in practice (real forms rarely nest beyond 3-4
   * levels).
   */
  private const MAX_DEPTH = 12;

  /**
   * Maximum pattern length we'll attempt to validate against.
   *
   * Patterns longer than this are treated as opaque (no validation) to
   * avoid ReDoS-class slowdowns.
   */
  private const MAX_PATTERN_LENGTH = 512;

  /**
   * {@inheritdoc}
   */
  public function buildElement(array $schema, mixed $defaultValue = NULL, array $hints = []): array {
    return $this->buildElementAtDepth($schema, $defaultValue, $hints, 0);
  }

  /**
   * Internal entry point carrying the recursion depth.
   *
   * @param array<string, mixed> $schema
   *   The JSON Schema describing the element.
   * @param mixed $defaultValue
   *   The pre-filled default value, if any.
   * @param array{required?: bool, title?: string|\Stringable, description?: string|\Stringable} $hints
   *   Hints for label, description, and required-ness.
   * @param int $depth
   *   The current recursion depth.
   */
  private function buildElementAtDepth(array $schema, mixed $defaultValue, array $hints, int $depth): array {
    if ($depth > self::MAX_DEPTH) {
      return [
        '#type' => 'item',
        '#markup' => '<em>' . $this->t('Schema too deeply nested to render.') . '</em>',
      ];
    }
    $type = $this->resolveType($schema);
    $title = $hints['title'] ?? $schema['title'] ?? NULL;
    $description = $hints['description'] ?? $schema['description'] ?? NULL;
    $required = (bool) ($hints['required'] ?? FALSE);

    // Use the schema's default if no override supplied.
    if ($defaultValue === NULL && array_key_exists('default', $schema)) {
      $defaultValue = $schema['default'];
    }

    // Enum → radios or select.
    if (!empty($schema['enum']) && is_array($schema['enum'])) {
      return $this->buildEnumElement($schema, $defaultValue, $title, $description, $required);
    }

    return match ($type) {
      'boolean' => $this->buildBooleanElement($schema, $defaultValue, $title, $description, $required),
      'integer', 'number' => $this->buildNumberElement($schema, $type, $defaultValue, $title, $description, $required),
      'array' => $this->buildArrayElement($schema, $defaultValue, $title, $description, $required),
      'object' => $this->buildObjectElement($schema, $defaultValue, $title, $description, $required, $depth),
      default => $this->buildStringElement($schema, $defaultValue, $title, $description, $required),
    };
  }

  /**
   * {@inheritdoc}
   */
  public function extractValue(array $schema, mixed $rawValue): mixed {
    $type = $this->resolveType($schema);

    // Enum (single): just string.
    if (!empty($schema['enum']) && $type !== 'array') {
      return $rawValue === '' || $rawValue === NULL ? NULL : (string) $rawValue;
    }

    return match ($type) {
      'boolean' => is_bool($rawValue) ? $rawValue : (bool) $rawValue,
      'integer' => $rawValue === '' || $rawValue === NULL ? NULL : (int) $rawValue,
      'number' => $rawValue === '' || $rawValue === NULL ? NULL : (float) $rawValue,
      'array' => $this->extractArrayValue($schema, $rawValue),
      'object' => $this->extractObjectValue($schema, $rawValue),
      default => $rawValue === NULL ? NULL : (string) $rawValue,
    };
  }

  /**
   * {@inheritdoc}
   */
  public function validateValue(array $schema, mixed $value, FormStateInterface $formState, array $parents = []): bool {
    $valid = TRUE;
    $type = $this->resolveType($schema);

    if ($value === NULL) {
      // Required-ness is enforced by Drupal element `#required` on render —
      // skip here to avoid duplicate errors.
      return TRUE;
    }

    // Enum membership.
    if (!empty($schema['enum']) && is_array($schema['enum']) && $type !== 'array') {
      if (!in_array($value, $schema['enum'], TRUE)) {
        $formState->setErrorByName(
          implode('][', $parents),
          $this->t('Value %v is not one of the allowed options.', ['%v' => (string) $value]),
        );
        $valid = FALSE;
      }
    }

    // String constraints.
    if ($type === 'string' && is_string($value)) {
      $length = mb_strlen($value);
      if (isset($schema['minLength']) && $length < (int) $schema['minLength']) {
        $formState->setErrorByName(implode('][', $parents), $this->t('Must be at least @n characters.', ['@n' => $schema['minLength']]));
        $valid = FALSE;
      }
      if (isset($schema['maxLength']) && $length > (int) $schema['maxLength']) {
        $formState->setErrorByName(implode('][', $parents), $this->t('Must be at most @n characters.', ['@n' => $schema['maxLength']]));
        $valid = FALSE;
      }
      if (isset($schema['pattern'])) {
        $compiled = $this->compilePattern((string) $schema['pattern']);
        if ($compiled !== NULL && preg_match($compiled, $value) !== 1) {
          $formState->setErrorByName(implode('][', $parents), $this->t('Does not match the required pattern.'));
          $valid = FALSE;
        }
      }
    }

    // Number constraints.
    if (($type === 'integer' || $type === 'number') && (is_int($value) || is_float($value))) {
      if (isset($schema['minimum']) && $value < $schema['minimum']) {
        $formState->setErrorByName(implode('][', $parents), $this->t('Must be at least @n.', ['@n' => $schema['minimum']]));
        $valid = FALSE;
      }
      if (isset($schema['maximum']) && $value > $schema['maximum']) {
        $formState->setErrorByName(implode('][', $parents), $this->t('Must be at most @n.', ['@n' => $schema['maximum']]));
        $valid = FALSE;
      }
    }

    // Recurse into objects.
    if ($type === 'object' && is_array($value) && isset($schema['properties']) && is_array($schema['properties'])) {
      $required = is_array($schema['required'] ?? NULL) ? $schema['required'] : [];
      foreach ($schema['properties'] as $propName => $propSchema) {
        if (!is_array($propSchema)) {
          continue;
        }
        $propValue = $value[$propName] ?? NULL;
        if ($propValue === NULL && in_array($propName, $required, TRUE)) {
          $formState->setErrorByName(implode('][', [...$parents, $propName]), $this->t('@field is required.', ['@field' => $propName]));
          $valid = FALSE;
          continue;
        }
        if (!$this->validateValue($propSchema, $propValue, $formState, [...$parents, $propName])) {
          $valid = FALSE;
        }
      }
    }

    return $valid;
  }

  /**
   * Element builders.
   *
   * Schema-driven builders for each supported JSON type.
   */
  private function buildBooleanElement(array $schema, mixed $default, mixed $title, mixed $description, bool $required): array {
    // Booleans always render as a checkbox here. The confirmation button
    // pair (presentation: confirmation) is handled outside this service —
    // see SchemaFormHandler — because buttons aren't a typical form value
    // and the extraction path differs.
    return [
      '#type' => 'checkbox',
      '#title' => $title ?? $this->t('Yes'),
      '#description' => $description,
      '#default_value' => (bool) $default,
      '#required' => $required,
    ];
  }

  /**
   * Builds a number/integer element.
   */
  private function buildNumberElement(array $schema, string $type, mixed $default, mixed $title, mixed $description, bool $required): array {
    $element = [
      '#type' => 'number',
      '#title' => $title,
      '#description' => $description,
      '#default_value' => $default,
      '#required' => $required,
    ];
    if (isset($schema['minimum'])) {
      $element['#min'] = $schema['minimum'];
    }
    if (isset($schema['maximum'])) {
      $element['#max'] = $schema['maximum'];
    }
    if ($type === 'integer') {
      $element['#step'] = 1;
    }
    return $element;
  }

  /**
   * Builds a string element, taking the optional format into account.
   */
  private function buildStringElement(array $schema, mixed $default, mixed $title, mixed $description, bool $required): array {
    $format = (string) ($schema['format'] ?? '');
    $element = [
      '#title' => $title,
      '#description' => $description,
      '#default_value' => is_string($default) ? $default : '',
      '#required' => $required,
    ];

    $element = match ($format) {
      'multiline' => $element + ['#type' => 'textarea', '#rows' => 5],
      'date' => $element + ['#type' => 'date'],
      'date-time' => $element + ['#type' => 'datetime'],
      'email' => $element + ['#type' => 'email'],
      'uri', 'url' => $element + ['#type' => 'url'],
      default => $element + ['#type' => 'textfield'],
    };

    if (isset($schema['maxLength'])) {
      $element['#maxlength'] = (int) $schema['maxLength'];
    }
    return $element;
  }

  /**
   * Builds an enum element rendered as a select or radio group.
   *
   * @param array<string, mixed> $schema
   *   The schema fragment carrying the enum constraint.
   * @param mixed $default
   *   The pre-filled default value, if any.
   * @param mixed $title
   *   The element title.
   * @param mixed $description
   *   The element description.
   * @param bool $required
   *   Whether the element is required.
   */
  private function buildEnumElement(array $schema, mixed $default, mixed $title, mixed $description, bool $required): array {
    $options = [];
    foreach ($schema['enum'] as $value) {
      $options[(string) $value] = (string) $value;
    }
    // Use select for >10 options, radios otherwise.
    $type = count($options) > 10 ? 'select' : 'radios';
    return [
      '#type' => $type,
      '#title' => $title,
      '#description' => $description,
      '#options' => $options,
      '#default_value' => $default !== NULL ? (string) $default : NULL,
      '#required' => $required,
      '#empty_option' => $type === 'select' ? $this->t('- Select -') : NULL,
    ];
  }

  /**
   * Builds an array element.
   *
   * Arrays currently render as either checkboxes (for arrays of enum
   * strings) or a JSON textarea fallback — neither recurses, so depth
   * doesn't need to be threaded. If per-item subforms are added here,
   * accept $depth and pass $depth + 1 to recursive buildElementAtDepth
   * calls.
   *
   * @param array<string, mixed> $schema
   *   The schema fragment describing the array.
   * @param mixed $default
   *   The pre-filled default value, if any.
   * @param mixed $title
   *   The element title.
   * @param mixed $description
   *   The element description.
   * @param bool $required
   *   Whether the element is required.
   */
  private function buildArrayElement(array $schema, mixed $default, mixed $title, mixed $description, bool $required): array {
    $items = is_array($schema['items'] ?? NULL) ? $schema['items'] : [];
    // Array of enum strings → checkboxes.
    if (!empty($items['enum']) && is_array($items['enum'])) {
      $options = [];
      foreach ($items['enum'] as $value) {
        $options[(string) $value] = (string) $value;
      }
      $defaultMap = [];
      if (is_array($default)) {
        foreach ($default as $v) {
          $defaultMap[(string) $v] = (string) $v;
        }
      }
      return [
        '#type' => 'checkboxes',
        '#title' => $title,
        '#description' => $description,
        '#options' => $options,
        '#default_value' => $defaultMap,
        '#required' => $required,
      ];
    }
    // Generic arrays — fall back to JSON textarea.
    return [
      '#type' => 'textarea',
      '#title' => $title ?? $this->t('Items (JSON)'),
      '#description' => $description ?? $this->t('Enter a JSON array.'),
      '#default_value' => $default !== NULL ? (string) json_encode($default, JSON_PRETTY_PRINT) : '[]',
      '#rows' => 6,
      '#required' => $required,
      '#flowdrop_json_payload' => TRUE,
    ];
  }

  /**
   * Builds an object element from a properties schema or JSON textarea.
   *
   * @param array<string, mixed> $schema
   *   The schema fragment describing the object.
   * @param mixed $default
   *   The pre-filled default value, if any.
   * @param mixed $title
   *   The element title.
   * @param mixed $description
   *   The element description.
   * @param bool $required
   *   Whether the element is required.
   * @param int $depth
   *   The current recursion depth.
   */
  private function buildObjectElement(array $schema, mixed $default, mixed $title, mixed $description, bool $required, int $depth): array {
    if (empty($schema['properties']) || !is_array($schema['properties'])) {
      // Free-form object → JSON textarea.
      return [
        '#type' => 'textarea',
        '#title' => $title ?? $this->t('Value (JSON)'),
        '#description' => $description ?? $this->t('Enter a valid JSON object.'),
        '#default_value' => $default !== NULL ? (string) json_encode($default, JSON_PRETTY_PRINT) : '{}',
        '#rows' => 8,
        '#required' => $required,
        '#flowdrop_json_payload' => TRUE,
      ];
    }
    $container = [
      '#type' => 'fieldset',
      '#title' => $title,
      '#description' => $description,
      '#tree' => TRUE,
    ];
    $requiredList = is_array($schema['required'] ?? NULL) ? $schema['required'] : [];
    foreach ($schema['properties'] as $propName => $propSchema) {
      if (!is_array($propSchema)) {
        continue;
      }
      $childDefault = is_array($default) ? ($default[$propName] ?? NULL) : NULL;
      $container[$propName] = $this->buildElementAtDepth($propSchema, $childDefault, [
        'required' => in_array($propName, $requiredList, TRUE),
      ], $depth + 1);
    }
    return $container;
  }

  // ---- Value extraction ----------------------------------------------------

  /**
   * Extracts and canonicalises an array value from the form submission.
   *
   * @param array<string, mixed> $schema
   *   The schema fragment describing the array.
   * @param mixed $rawValue
   *   The raw submitted value.
   */
  private function extractArrayValue(array $schema, mixed $rawValue): array {
    $items = is_array($schema['items'] ?? NULL) ? $schema['items'] : [];
    if (!empty($items['enum'])) {
      // Drupal checkboxes: [value => value_or_0].
      return is_array($rawValue) ? array_values(array_filter($rawValue)) : [];
    }
    if (is_string($rawValue)) {
      $decoded = json_decode($rawValue, TRUE);
      return is_array($decoded) ? $decoded : [];
    }
    return is_array($rawValue) ? $rawValue : [];
  }

  /**
   * Extracts and canonicalises an object value from the form submission.
   *
   * @param array<string, mixed> $schema
   *   The schema fragment describing the object.
   * @param mixed $rawValue
   *   The raw submitted value.
   */
  private function extractObjectValue(array $schema, mixed $rawValue): array {
    if (empty($schema['properties']) || !is_array($schema['properties'])) {
      // Free-form: parse JSON.
      if (is_string($rawValue)) {
        $decoded = json_decode($rawValue, TRUE);
        return is_array($decoded) ? $decoded : [];
      }
      return is_array($rawValue) ? $rawValue : [];
    }
    if (!is_array($rawValue)) {
      return [];
    }
    $result = [];
    foreach ($schema['properties'] as $propName => $propSchema) {
      if (!is_array($propSchema)) {
        continue;
      }
      $raw = $rawValue[$propName] ?? NULL;
      $result[$propName] = $this->extractValue($propSchema, $raw);
    }
    return $result;
  }

  /**
   * Resolves the schema's type to a single canonical string.
   *
   * @param array<string, mixed> $schema
   *   The schema fragment to inspect.
   */
  private function resolveType(array $schema): string {
    $type = $schema['type'] ?? 'string';
    if (is_array($type)) {
      foreach ($type as $candidate) {
        if (is_string($candidate) && $candidate !== 'null') {
          return $candidate;
        }
      }
      return 'string';
    }
    return is_string($type) ? $type : 'string';
  }

  /**
   * Compiles a JSON Schema pattern to a PCRE-compatible regex.
   *
   * Returns NULL when the pattern is too long, contains the chosen
   * delimiter unescaped, or does not compile. Callers treat NULL as "no
   * pattern validation possible" — they do not fail closed, because that
   * would break schema imports for non-PCRE patterns or ones that use a
   * delimiter character.
   *
   * Note on ReDoS: the 512-char cap reduces but does not eliminate the
   * risk of catastrophic backtracking from attacker-controlled patterns.
   * PHP's `pcre.backtrack_limit` provides a backstop; consider tightening
   * it via php.ini for sites that expose schema authoring to untrusted
   * users.
   */
  private function compilePattern(string $pattern): ?string {
    if ($pattern === '' || strlen($pattern) > self::MAX_PATTERN_LENGTH) {
      return NULL;
    }
    // Use `#` as the delimiter — uncommon in user-authored regex patterns.
    // If the pattern contains an unescaped `#`, refuse to compile rather
    // than guess at the author's intent.
    if (preg_match('/(?<!\\\\)#/', $pattern) === 1) {
      return NULL;
    }
    $compiled = '#' . $pattern . '#u';
    // Probe-compile against an empty string; suppress only the warning
    // surface, not the error code from preg_match.
    set_error_handler(static fn () => TRUE);
    try {
      $result = preg_match($compiled, '');
    }
    finally {
      restore_error_handler();
    }
    if ($result === FALSE || preg_last_error() !== PREG_NO_ERROR) {
      return NULL;
    }
    return $compiled;
  }

}
