<?php

namespace Drupal\ctools;

use Drupal\Core\Plugin\Context\Context;
use Drupal\Core\Plugin\Context\ContextDefinition;
use Drupal\Core\Plugin\Context\ContextInterface;
use Drupal\Core\Plugin\Context\EntityContextDefinition;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\TypedData\ComplexDataDefinitionInterface;
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
use Drupal\Core\TypedData\DataReferenceInterface;
use Drupal\Core\TypedData\ListDataDefinitionInterface;
use Drupal\Core\TypedData\ListInterface;
use Drupal\Core\TypedData\TypedDataManagerInterface;

/**
 * Typed Data Resolver Service.
 */
class TypedDataResolver {

  /**
   * The typed data manager.
   *
   * @var \Drupal\Core\TypedData\TypedDataManagerInterface
   */
  protected $manager;

  /**
   * The string translation service.
   *
   * @var \Drupal\Core\StringTranslation\TranslationInterface
   */
  protected $translation;

  /**
   * Typed Data Resolver Service constructor.
   *
   * @param \Drupal\Core\TypedData\TypedDataManagerInterface $manager
   *   The typed data manager.
   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
   *   The string translation service.
   */
  public function __construct(TypedDataManagerInterface $manager, TranslationInterface $translation) {
    $this->manager = $manager;
    $this->translation = $translation;
  }

  /**
   * Convert a property to a context.
   *
   * This method will respect the value of contexts as well, so if a context
   * object is pass that contains a value, the appropriate value will be
   * extracted and injected into the resulting context object if available.
   *
   * @param string $property_path
   *   The name of the property.
   * @param \Drupal\Core\Plugin\Context\ContextInterface $context
   *   The context from which we will extract values if available.
   *
   * @return \Drupal\Core\Plugin\Context\Context
   *   A context object that represents the definition & value of the property.
   *
   * @throws \Exception
   */
  public function getContextFromProperty(string $property_path, ContextInterface $context) {
    $value = NULL;
    $data_definition = NULL;
    if ($context->hasContextValue()) {
      /** @var \Drupal\Core\TypedData\ComplexDataInterface $data */
      $data = $context->getContextData();
      foreach (explode(':', $property_path) as $name) {

        if ($data instanceof ListInterface) {
          if (!is_numeric($name)) {
            // Implicitly default to delta 0 for lists when not specified.
            $data = $data->first();
          }
          else {
            // If we have a delta, fetch it and continue with the next part.
            $data = $data->get($name);
            continue;
          }
        }

        // Forward to the target value if this is a data reference.
        if ($data instanceof DataReferenceInterface) {
          $data = $data->getTarget();
        }

        if (!$data->getDataDefinition()->getPropertyDefinition($name)) {
          throw new \Exception("Unknown property $name in property path $property_path");
        }
        $data = $data->get($name);
      }

      $value = $data->getValue();
      $data_definition = $data instanceof DataReferenceInterface ? $data->getDataDefinition()->getTargetDefinition() : $data->getDataDefinition();
    }
    else {
      /** @var \Drupal\Core\TypedData\ComplexDataDefinitionInterface $data_definition */
      $data_definition = $context->getContextDefinition()->getDataDefinition();
      foreach (explode(':', $property_path) as $name) {

        if ($data_definition instanceof ListDataDefinitionInterface) {
          $data_definition = $data_definition->getItemDefinition();

          // If the delta was specified explicitly, continue with the next part.
          if (is_numeric($name)) {
            continue;
          }
        }

        // Forward to the target definition if this is a data reference
        // definition.
        if ($data_definition instanceof DataReferenceDefinitionInterface) {
          $data_definition = $data_definition->getTargetDefinition();
        }

        if (!$data_definition->getPropertyDefinition($name)) {
          throw new \Exception("Unknown property $name in property path $property_path");
        }
        $data_definition = $data_definition->getPropertyDefinition($name);
      }

      // Forward to the target definition if this is a data reference
      // definition.
      if ($data_definition instanceof DataReferenceDefinitionInterface) {
        $data_definition = $data_definition->getTargetDefinition();
      }
    }
    if (strpos($data_definition->getDataType(), 'entity:') === 0) {
      $context_definition = new EntityContextDefinition($data_definition->getDataType(), $data_definition->getLabel(), $data_definition->isRequired(), FALSE, $data_definition->getDescription());
    }
    else {
      $context_definition = new ContextDefinition($data_definition->getDataType(), $data_definition->getLabel(), $data_definition->isRequired(), FALSE, $data_definition->getDescription());
    }
    return new Context($context_definition, $value);
  }

  /**
   * Extracts a context from an array of contexts by a tokenized pattern.
   *
   * This is more than simple isset/empty checks on the contexts array. The
   * pattern could be node:uid:name which will iterate over all provided
   * contexts in the array for one named 'node', it will then load the data
   * definition of 'node' and check for a property named 'uid'. This will then
   * set a new (temporary) context on the array and recursively call itself to
   * navigate through related properties all the way down until the request
   * property is located. At that point the property is passed to a
   * TypedDataResolver which will convert it to an appropriate ContextInterface
   * object.
   *
   * @param string $token
   *   A ":" delimited set of tokens representing
   * @param \Drupal\Core\Plugin\Context\ContextInterface[] $contexts
   *   The array of available contexts.
   *
   * @return \Drupal\Core\Plugin\Context\ContextInterface
   *   The requested token as a full Context object.
   *
   * @throws \Drupal\ctools\ContextNotFoundException
   */
  public function convertTokenToContext(string $token, array $contexts) {
    // If the requested token is already a context, just return it.
    if (isset($contexts[$token])) {
      return $contexts[$token];
    }
    else {
      [$base, $property_path] = explode(':', $token, 2);
      // A base must always be set. This method recursively calls itself
      // setting bases for this reason.
      if (!empty($contexts[$base])) {
        return $this->getContextFromProperty($property_path, $contexts[$base]);
      }
      // @todo improve this exception message.
      throw new ContextNotFoundException("The requested context was not found in the supplied array of contexts.");
    }
  }

  /**
   * Provides an administrative label for a tokenized relationship.
   *
   * @param string $token
   *   The token related to a context in the contexts array.
   * @param \Drupal\Core\Plugin\Context\ContextInterface[] $contexts
   *   An array of contexts from which to extract our token's label.
   *
   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
   *   The administrative label of $token.
   */
  public function getLabelByToken(string $token, array $contexts) {
    // @todo Optimize this by allowing to limit the desired token?
    $tokens = $this->getTokensForContexts($contexts);
    if (isset($tokens[$token])) {
      return $tokens[$token];
    }
  }

  /**
   * Extracts an array of tokens and labels.
   *
   * @param \Drupal\Core\Plugin\Context\ContextInterface[] $contexts
   *   The array of contexts with which we are currently dealing.
   *
   * @return array
   *   An array of token keys and corresponding labels.
   */
  public function getTokensForContexts(array $contexts) {
    $tokens = [];
    foreach ($contexts as $context_id => $context) {
      $data_definition = $context->getContextDefinition()->getDataDefinition();
      if ($data_definition instanceof ComplexDataDefinitionInterface) {
        foreach ($this->getTokensFromComplexData($data_definition) as $token => $label) {
          $tokens["$context_id:$token"] = $data_definition->getLabel() . ': ' . $label;
        }
      }
    }
    return $tokens;
  }

  /**
   * Returns tokens for a complex data definition.
   *
   * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface $complex_data_definition
   *   Complex Data Definition.
   *
   * @return array
   *   An array of token keys and corresponding labels.
   */
  protected function getTokensFromComplexData(ComplexDataDefinitionInterface $complex_data_definition) {
    $tokens = [];
    // Loop over all properties.
    foreach ($complex_data_definition->getPropertyDefinitions() as $property_name => $property_definition) {

      // Item definitions do not always have a label. Use the list definition
      // label if the item does not have one.
      $property_label = $property_definition->getLabel();
      if ($property_definition instanceof ListDataDefinitionInterface) {
        $property_definition = $property_definition->getItemDefinition();
        $property_label = $property_definition->getLabel() ?: $property_label;
      }

      // If the property is complex too, recurse to find child properties.
      if ($property_definition instanceof ComplexDataDefinitionInterface) {
        $property_tokens = $this->getTokensFromComplexData($property_definition);
        foreach ($property_tokens as $token => $label) {
          $tokens[$property_name . ':' . $token] = count($property_tokens) > 1 ? ($property_label . ': ' . $label) : $property_label;
        }
      }

      // Only expose references as tokens.
      // @todo Consider to expose primitive and non-reference typed data
      //   definitions too, like strings, integers and dates. The current UI
      //   will not scale to that.
      if ($property_definition instanceof DataReferenceDefinitionInterface) {
        $tokens[$property_name] = $property_definition->getLabel();
      }
    }
    return $tokens;
  }

}
