<?php

declare(strict_types=1);

namespace Drupal\flowdrop_runtime\Service\JsonPath;

use Drupal\Component\Serialization\Json;
use Drupal\flowdrop\Service\JsonPath\JsonPathServiceInterface;
use Loilo\JsonPath\JsonPath;
use Psr\Log\LoggerInterface;
use Symfony\Component\PropertyAccess\Exception\AccessException;
use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
 * Service for JSONPath (RFC 9535) data extraction.
 *
 * Provides a unified interface for querying data using either:
 * - JSONPath expressions (starting with "$")
 * - Simple property paths (dot or bracket notation)
 *
 * This allows gradual adoption of JSONPath while maintaining backward
 * compatibility with existing property path configurations.
 *
 * @example JSONPath expressions:
 *   - "$.users[*].name" - All user names
 *   - "$.items[?(@.status == 'active')]" - Items where status is active
 *   - "$..author" - All author fields recursively
 *   - "$.books[-1]" - Last book
 *   - "$.store.book[0:5]" - First 5 books
 *
 * @example Property paths (legacy support):
 *   - "users.0.name" or "[users][0][name]"
 *   - "entity.field_value"
 *
 * @internal This class is not part of the public API and may change without notice.
 */
class JsonPathService implements JsonPathServiceInterface {

  /**
   * The Symfony PropertyAccess service.
   *
   * @var \Symfony\Component\PropertyAccess\PropertyAccessorInterface
   */
  protected PropertyAccessorInterface $propertyAccessor;

  /**
   * Cache of compiled JSONPath queries.
   *
   * @var array<string, \Loilo\JsonPath\JsonPath>
   */
  protected array $queryCache = [];

  /**
   * Constructs a JsonPathService.
   *
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger service.
   */
  public function __construct(
    protected LoggerInterface $logger,
  ) {
    $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
  }

  /**
   * {@inheritdoc}
   */
  public function extract(mixed $data, string $path, mixed $default = NULL): mixed {
    if (empty($path)) {
      return $data;
    }

    // Normalize data if it's a JSON string.
    $data = $this->normalizeData($data);

    if ($this->isJsonPath($path)) {
      return $this->extractJsonPath($data, $path, $default);
    }

    return $this->extractPropertyPath($data, $path, $default);
  }

  /**
   * {@inheritdoc}
   */
  public function extractFirst(mixed $data, string $path, mixed $default = NULL): mixed {
    if (empty($path)) {
      return $data;
    }

    $data = $this->normalizeData($data);

    // For JSONPath, get raw results and return first match directly.
    if ($this->isJsonPath($path)) {
      try {
        $jsonPath = $this->getCompiledQuery($path);
        $results = $jsonPath->find($data);

        // Return first match or default if no matches.
        return !empty($results) ? $results[0] : $default;
      }
      catch (\Throwable $e) {
        $this->logger->warning("JSONPath query failed for '@path': @error", [
          "@path" => $path,
          "@error" => $e->getMessage(),
        ]);
        return $default;
      }
    }

    // For property paths, use existing extraction.
    return $this->extractPropertyPath($data, $path, $default);
  }

  /**
   * {@inheritdoc}
   */
  public function extractAll(mixed $data, string $path): array {
    if (empty($path)) {
      return is_array($data) ? $data : [$data];
    }

    $data = $this->normalizeData($data);

    if (!$this->isJsonPath($path)) {
      // Convert property path result to array for consistency.
      $result = $this->extractPropertyPath($data, $path, NULL);
      return $result !== NULL ? [$result] : [];
    }

    try {
      $jsonPath = $this->getCompiledQuery($path);
      return $jsonPath->find($data);
    }
    catch (\Throwable $e) {
      $this->logger->warning("JSONPath extraction failed for path '@path': @error", [
        "@path" => $path,
        "@error" => $e->getMessage(),
      ]);
      return [];
    }
  }

  /**
   * {@inheritdoc}
   */
  public function exists(mixed $data, string $path): bool {
    if (empty($path)) {
      return TRUE;
    }

    $data = $this->normalizeData($data);

    if ($this->isJsonPath($path)) {
      $results = $this->extractAll($data, $path);
      return !empty($results);
    }

    // For property paths, check if readable.
    if (!is_array($data) && !is_object($data)) {
      return FALSE;
    }

    try {
      $normalizedPath = $this->normalizePropertyPath($path);
      return $this->propertyAccessor->isReadable($data, $normalizedPath);
    }
    catch (\Throwable $e) {
      return FALSE;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function applyMappings(mixed $sourceData, array $mappings): array {
    $result = [];

    foreach ($mappings as $outputKey => $pathExpression) {
      // Check if the value is a literal (not a path expression).
      if ($this->isLiteralValue($pathExpression)) {
        $result[$outputKey] = $this->parseLiteralValue($pathExpression);
        continue;
      }

      // Extract using the path expression.
      $result[$outputKey] = $this->extractFirst($sourceData, $pathExpression);
    }

    return $result;
  }

  /**
   * {@inheritdoc}
   */
  public function isJsonPath(string $path): bool {
    return str_starts_with(trim($path), "$");
  }

  /**
   * {@inheritdoc}
   */
  public function isValidJsonPath(string $path): bool {
    if (!$this->isJsonPath($path)) {
      return FALSE;
    }

    try {
      $this->getCompiledQuery($path);
      return TRUE;
    }
    catch (\Throwable $e) {
      return FALSE;
    }
  }

  /**
   * Extract data using JSONPath expression.
   *
   * @param mixed $data
   *   The data structure.
   * @param string $path
   *   The JSONPath expression.
   * @param mixed $default
   *   Default value if no matches.
   *
   * @return mixed
   *   The extracted value(s) or default.
   */
  protected function extractJsonPath(mixed $data, string $path, mixed $default): mixed {
    try {
      $jsonPath = $this->getCompiledQuery($path);
      $results = $jsonPath->find($data);

      if (empty($results)) {
        return $default;
      }

      // If single result, return it directly for convenience.
      // Unless the path explicitly requests multiple values.
      if (count($results) === 1 && !$this->isMultiValuePath($path)) {
        return $results[0];
      }

      return $results;
    }
    catch (\Throwable $e) {
      $this->logger->warning("JSONPath query failed for '@path': @error", [
        "@path" => $path,
        "@error" => $e->getMessage(),
      ]);
      return $default;
    }
  }

  /**
   * Extract data using Symfony PropertyAccess.
   *
   * @param mixed $data
   *   The data structure.
   * @param string $path
   *   The property path.
   * @param mixed $default
   *   Default value if not found.
   *
   * @return mixed
   *   The extracted value or default.
   */
  protected function extractPropertyPath(mixed $data, string $path, mixed $default): mixed {
    if (!is_array($data) && !is_object($data)) {
      return $default;
    }

    try {
      $normalizedPath = $this->normalizePropertyPath($path);

      if ($this->propertyAccessor->isReadable($data, $normalizedPath)) {
        return $this->propertyAccessor->getValue($data, $normalizedPath);
      }

      return $default;
    }
    catch (AccessException | UnexpectedTypeException $e) {
      return $default;
    }
    catch (\Throwable $e) {
      $this->logger->warning("Property path extraction failed for '@path': @error", [
        "@path" => $path,
        "@error" => $e->getMessage(),
      ]);
      return $default;
    }
  }

  /**
   * Get a compiled JSONPath query, using cache if available.
   *
   * @param string $path
   *   The JSONPath expression.
   *
   * @return \Loilo\JsonPath\JsonPath
   *   The compiled JSONPath query object.
   *
   * @throws \Exception
   *   If the path is invalid.
   */
  protected function getCompiledQuery(string $path): JsonPath {
    if (!isset($this->queryCache[$path])) {
      $this->queryCache[$path] = new JsonPath($path);
    }

    return $this->queryCache[$path];
  }

  /**
   * Normalize data for querying.
   *
   * Converts JSON strings to arrays/objects.
   *
   * @param mixed $data
   *   The input data.
   *
   * @return mixed
   *   Normalized data structure.
   */
  protected function normalizeData(mixed $data): mixed {
    if (is_string($data)) {
      // Try to decode as JSON.
      $decoded = Json::decode($data);
      if ($decoded !== NULL || $data === "null") {
        return $decoded;
      }
    }

    return $data;
  }

  /**
   * Normalize a property path to Symfony PropertyAccess format.
   *
   * Converts dot notation (user.name) to bracket notation ([user][name]).
   *
   * @param string $path
   *   The property path.
   *
   * @return string
   *   The normalized path.
   */
  protected function normalizePropertyPath(string $path): string {
    // If already in bracket notation, return as-is.
    if (str_starts_with($path, "[")) {
      return $path;
    }

    // Convert dot notation to bracket notation.
    $parts = explode(".", $path);
    $normalizedParts = array_map(
      static fn(string $part): string => "[{$part}]",
      $parts
    );

    return implode("", $normalizedParts);
  }

  /**
   * Check if a JSONPath expression can return multiple values.
   *
   * @param string $path
   *   The JSONPath expression.
   *
   * @return bool
   *   TRUE if the path may return multiple values.
   */
  protected function isMultiValuePath(string $path): bool {
    // Wildcards, filters, and recursive descent can return multiple values.
    return str_contains($path, "[*]")
      || str_contains($path, "[?")
      || str_contains($path, "..")
    // Slice notation.
      || preg_match('/\[\d+:\d*\]/', $path) === 1;
  }

  /**
   * Check if a mapping value is a literal (not a path expression).
   *
   * @param string $value
   *   The mapping value to check.
   *
   * @return bool
   *   TRUE if the value is a literal.
   */
  protected function isLiteralValue(string $value): bool {
    // Literals are prefixed with "literal:" or wrapped in quotes.
    return str_starts_with($value, "literal:")
      || (str_starts_with($value, '"') && str_ends_with($value, '"'))
      || (str_starts_with($value, "'") && str_ends_with($value, "'"));
  }

  /**
   * Parse a literal value from a mapping.
   *
   * @param string $value
   *   The literal value string.
   *
   * @return mixed
   *   The parsed literal value.
   */
  protected function parseLiteralValue(string $value): mixed {
    if (str_starts_with($value, "literal:")) {
      return substr($value, 8);
    }

    // Remove surrounding quotes.
    if ((str_starts_with($value, '"') && str_ends_with($value, '"'))
      || (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
      return substr($value, 1, -1);
    }

    return $value;
  }

}
