<?php

declare(strict_types=1);

namespace Drupal\flowdrop_node_processor\Service;

use JsonSchema\Validator;
use JsonSchema\Constraints\Constraint;

/**
 * Service for validating data against JSON Schema.
 *
 * Uses justinrainbow/json-schema library for validation.
 * Supports both strict and soft validation modes.
 *
 * @internal This class is not part of the public API and may change without notice.
 */
class JsonSchemaValidator {

  /**
   * Validate data against a JSON Schema.
   *
   * @param mixed $data
   *   The data to validate.
   * @param array<string, mixed> $schema
   *   The JSON Schema definition.
   * @param string $mode
   *   Validation mode: "strict" or "soft".
   *   - strict: Throws exception on validation failure.
   *   - soft: Returns validation result without throwing.
   *
   * @return array{valid: bool, errors: array<int, array{property: string, message: string}>}
   *   Validation result with valid flag and array of errors.
   *
   * @throws \InvalidArgumentException
   *   When validation fails in strict mode.
   */
  public function validate(mixed $data, array $schema, string $mode = "soft"): array {
    // If no schema provided, consider it valid.
    if (empty($schema)) {
      return [
        "valid" => TRUE,
        "errors" => [],
      ];
    }

    // Convert PHP array to object for json-schema library.
    $dataObject = $this->convertToObject($data);
    $schemaObject = $this->convertToObject($schema);

    // Create validator and validate.
    $validator = new Validator();
    $validator->validate(
      $dataObject,
      $schemaObject,
      Constraint::CHECK_MODE_TYPE_CAST
    );

    // Collect errors.
    $errors = [];
    if (!$validator->isValid()) {
      foreach ($validator->getErrors() as $error) {
        $errors[] = [
          "property" => $error["property"] ?? "",
          "message" => $error["message"] ?? "Unknown validation error",
        ];
      }
    }

    $isValid = $validator->isValid();

    // In strict mode, throw exception if invalid.
    if ($mode === "strict" && !$isValid) {
      $errorMessages = array_map(
        fn(array $err): string => sprintf("[%s] %s", $err["property"], $err["message"]),
        $errors
      );
      throw new \InvalidArgumentException(
        sprintf("Schema validation failed: %s", implode("; ", $errorMessages))
      );
    }

    return [
      "valid" => $isValid,
      "errors" => $errors,
    ];
  }

  /**
   * Convert PHP array to object recursively for json-schema validation.
   *
   * The json-schema library expects stdClass objects for object validation.
   *
   * @param mixed $data
   *   The data to convert.
   *
   * @return mixed
   *   The converted data with arrays as stdClass where appropriate.
   */
  protected function convertToObject(mixed $data): mixed {
    if (!is_array($data)) {
      return $data;
    }

    // Check if this is an associative array (object) or sequential array.
    if ($this->isAssociativeArray($data)) {
      $object = new \stdClass();
      foreach ($data as $key => $value) {
        $object->{$key} = $this->convertToObject($value);
      }
      return $object;
    }

    // Sequential array - convert items but keep as array.
    return array_map([$this, "convertToObject"], $data);
  }

  /**
   * Check if an array is associative (has string keys).
   *
   * @param array<mixed> $array
   *   The array to check.
   *
   * @return bool
   *   TRUE if associative, FALSE if sequential.
   */
  protected function isAssociativeArray(array $array): bool {
    if (empty($array)) {
      return FALSE;
    }

    return array_keys($array) !== range(0, count($array) - 1);
  }

  /**
   * Generate a default schema from sample data.
   *
   * This is a helper method for users to generate initial schemas.
   *
   * @param mixed $data
   *   The sample data.
   *
   * @return array<string, mixed>
   *   A basic JSON Schema derived from the data.
   */
  public function generateSchemaFromData(mixed $data): array {
    return $this->inferSchema($data);
  }

  /**
   * Infer schema from data recursively.
   *
   * @param mixed $data
   *   The data to infer schema from.
   *
   * @return array<string, mixed>
   *   The inferred schema.
   */
  protected function inferSchema(mixed $data): array {
    if ($data === NULL) {
      return ["type" => "null"];
    }

    if (is_bool($data)) {
      return ["type" => "boolean"];
    }

    if (is_int($data)) {
      return ["type" => "integer"];
    }

    if (is_float($data)) {
      return ["type" => "number"];
    }

    if (is_string($data)) {
      return ["type" => "string"];
    }

    if (is_array($data)) {
      // Check if sequential or associative.
      if (empty($data)) {
        return ["type" => "array", "items" => []];
      }

      if ($this->isAssociativeArray($data)) {
        $properties = [];
        foreach ($data as $key => $value) {
          $properties[$key] = $this->inferSchema($value);
        }
        return [
          "type" => "object",
          "properties" => $properties,
        ];
      }

      // Sequential array - infer from first item.
      return [
        "type" => "array",
        "items" => $this->inferSchema($data[0]),
      ];
    }

    if (is_object($data)) {
      $properties = [];
      foreach (get_object_vars($data) as $key => $value) {
        $properties[$key] = $this->inferSchema($value);
      }
      return [
        "type" => "object",
        "properties" => $properties,
      ];
    }

    return ["type" => "mixed"];
  }

}
