<?php

declare(strict_types=1);

namespace Drupal\flowdrop_node_processor\Service;

use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyAccess\Exception\AccessException;
use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException;

/**
 * Service for mapping data from source to target structure.
 *
 * Uses Symfony PropertyAccessor to extract values from source data
 * and build a new structure based on mapping configuration.
 *
 * Supports:
 * - Simple scalar mappings: "target.path" => "[source][path]"
 * - Array iteration: "_source" + "_each" for mapping array items
 * - Special values:
 *    _NOW_,
 *   _LITERAL:value,
 *   _NULL_,
 *   _EMPTY_ARRAY_,
 *   _EMPTY_OBJECT_
 */
class DataMapperService {

  /**
   * The property accessor instance.
   *
   * @var \Symfony\Component\PropertyAccess\PropertyAccessorInterface
   */
  protected PropertyAccessorInterface $propertyAccessor;

  /**
   * Count of successfully mapped fields.
   *
   * @var int
   */
  protected int $mappedFieldsCount = 0;

  /**
   * Constructs a DataMapperService.
   */
  public function __construct() {
    $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
  }

  /**
   * Map source data to target structure using mapping configuration.
   *
   * @param mixed $sourceData
   *   The source data to extract values from.
   * @param array<string, mixed> $mapping
   *   The mapping configuration where keys are target paths
   *   and values are source paths or nested array configurations.
   *
   * @return array{data: array<string, mixed>, mappedFields: int}
   *   An array containing the mapped data and count of mapped fields.
   */
  public function map(mixed $sourceData, array $mapping): array {
    $this->mappedFieldsCount = 0;
    $result = [];

    foreach ($mapping as $targetPath => $sourceConfig) {
      $value = $this->resolveValue($sourceData, $sourceConfig);
      $this->setNestedValue($result, $targetPath, $value);
    }

    return [
      "data" => $result,
      "mappedFields" => $this->mappedFieldsCount,
    ];
  }

  /**
   * Resolve a value from the source data based on configuration.
   *
   * @param mixed $sourceData
   *   The source data.
   * @param mixed $sourceConfig
   *   The source configuration (string path or array for iteration).
   *
   * @return mixed
   *   The resolved value.
   */
  protected function resolveValue(mixed $sourceData, mixed $sourceConfig): mixed {
    // Handle array iteration configuration.
    if (is_array($sourceConfig) && isset($sourceConfig["_source"])) {
      return $this->resolveArrayMapping($sourceData, $sourceConfig);
    }

    // Handle string path or special values.
    if (is_string($sourceConfig)) {
      return $this->resolveStringValue($sourceData, $sourceConfig);
    }

    // Return as-is for other types (literal values).
    return $sourceConfig;
  }

  /**
   * Resolve a string value (path or special value).
   *
   * @param mixed $sourceData
   *   The source data.
   * @param string $sourceConfig
   *   The source path or special value.
   *
   * @return mixed
   *   The resolved value.
   */
  protected function resolveStringValue(mixed $sourceData, string $sourceConfig): mixed {
    // Handle special values.
    $specialValue = $this->resolveSpecialValue($sourceConfig);
    if ($specialValue !== NULL) {
      $this->mappedFieldsCount++;
      return $specialValue["value"];
    }

    // Handle PropertyAccessor path.
    return $this->extractValue($sourceData, $sourceConfig);
  }

  /**
   * Resolve special values like _NOW_, _LITERAL:, etc.
   *
   * @param string $value
   *   The value to check.
   *
   * @return array{value: mixed}|null
   *   An array with the resolved value, or null if not a special value.
   */
  protected function resolveSpecialValue(string $value): ?array {
    switch ($value) {
      case "_NOW_":
        return ["value" => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)];

      case "_NULL_":
        return ["value" => NULL];

      case "_EMPTY_ARRAY_":
        return ["value" => []];

      case "_EMPTY_OBJECT_":
        return ["value" => new \stdClass()];
    }

    // Handle _LITERAL:value pattern.
    if (str_starts_with($value, "_LITERAL:")) {
      return ["value" => substr($value, 9)];
    }

    return NULL;
  }

  /**
   * Extract a value from source data using PropertyAccessor path.
   *
   * @param mixed $sourceData
   *   The source data.
   * @param string $path
   *   The PropertyAccessor path (e.g., "[users][0][name]").
   *
   * @return mixed
   *   The extracted value or null if path doesn't exist.
   */
  protected function extractValue(mixed $sourceData, string $path): mixed {
    // Handle empty path - return entire source.
    if (empty($path)) {
      $this->mappedFieldsCount++;
      return $sourceData;
    }

    // Ensure source is accessible.
    if (!is_array($sourceData) && !is_object($sourceData)) {
      return NULL;
    }

    try {
      if ($this->propertyAccessor->isReadable($sourceData, $path)) {
        $value = $this->propertyAccessor->getValue($sourceData, $path);
        $this->mappedFieldsCount++;
        return $value;
      }
    }
    catch (AccessException | UnexpectedTypeException $e) {
      // Path not accessible, return null.
    }

    return NULL;
  }

  /**
   * Resolve array mapping with _source and _each configuration.
   *
   * @param mixed $sourceData
   *   The source data.
   * @param array<string, mixed> $config
   *   The array mapping configuration with _source and _each keys.
   *
   * @return array<int, mixed>
   *   The mapped array.
   */
  protected function resolveArrayMapping(mixed $sourceData, array $config): array {
    $sourcePath = $config["_source"] ?? "";
    $eachMapping = $config["_each"] ?? [];

    // Extract the source array.
    $sourceArray = $this->extractValue($sourceData, $sourcePath);

    // If source is not an array or is empty, return empty array.
    if (!is_array($sourceArray)) {
      return [];
    }

    // If no _each mapping, return the source array as-is.
    if (empty($eachMapping) || !is_array($eachMapping)) {
      return $sourceArray;
    }

    // Map each item in the source array.
    $result = [];
    foreach ($sourceArray as $item) {
      $mappedItem = [];
      foreach ($eachMapping as $targetKey => $sourcePath) {
        // Handle nested array mappings within items.
        if (is_array($sourcePath) && isset($sourcePath["_source"])) {
          $mappedItem[$targetKey] = $this->resolveArrayMapping($item, $sourcePath);
        }
        elseif (is_string($sourcePath)) {
          $mappedItem[$targetKey] = $this->resolveStringValue($item, $sourcePath);
        }
        else {
          $mappedItem[$targetKey] = $sourcePath;
        }
      }
      $result[] = $mappedItem;
    }

    return $result;
  }

  /**
   * Set a value in a nested array structure using dot notation.
   *
   * @param array<string, mixed> $array
   *   The array to modify (passed by reference).
   * @param string $path
   *   The dot-notation path (e.g., "user.profile.name").
   * @param mixed $value
   *   The value to set.
   */
  protected function setNestedValue(array &$array, string $path, mixed $value): void {
    $keys = explode(".", $path);
    $current = &$array;

    foreach ($keys as $i => $key) {
      // If this is the last key, set the value.
      if ($i === count($keys) - 1) {
        $current[$key] = $value;
        return;
      }

      // Create nested array if it doesn't exist.
      if (!isset($current[$key]) || !is_array($current[$key])) {
        $current[$key] = [];
      }

      $current = &$current[$key];
    }
  }

}
