<?php

namespace Drupal\dkan_datastore\Storage;

use Drupal\dkan_common\Storage\Query;
use Drupal\dkan_datastore\Service\DatastoreQuery;

/**
 * Product a Query object based on a Datastore Query.
 */
class QueryFactory {

  /**
   * Datastore Query object for conversion.
   *
   * @var Drupal\dkan_datastore\Service\DatastoreQuery
   */
  private $datastoreQuery;

  /**
   * Storage map array of storage objects keyed by resource alias.
   *
   * @var array
   */
  private $storageMap;

  /**
   * Constructor.
   *
   * @param Drupal\dkan_datastore\Service\DatastoreQuery $datastoreQuery
   *   Datastore query request object.
   * @param array $storageMap
   *   Storage map array.
   */
  public function __construct(DatastoreQuery $datastoreQuery, array $storageMap) {
    $this->datastoreQuery = $datastoreQuery;
    $this->storageMap = $storageMap;
  }

  /**
   * Static factory create method.
   *
   * @param \Drupal\dkan_datastore\Service\DatastoreQuery $datastoreQuery
   *   Datastore query request object.
   * @param array $storageMap
   *   Storage map array.
   *
   * @return \Drupal\dkan_common\Storage\Query
   *   DKAN query object.
   */
  public static function create(DatastoreQuery $datastoreQuery, array $storageMap): Query {
    $factory = new self($datastoreQuery, $storageMap);
    return $factory->populateQuery();
  }

  /**
   * Create Query and populate with properties with DatastoreQuery object.
   *
   * @return Drupal\dkan_common\Storage\Query
   *   Query object.
   */
  public function populateQuery(): Query {
    $query = new Query();

    $this->populateQueryProperties($query);
    $this->populateQueryConditions($query);
    $this->populateQueryJoins($query);
    $this->populateQueryGroupBy($query);
    $this->populateQuerySorts($query);
    if ($this->datastoreQuery->{"$.limit"}) {
      $query->limit = $this->datastoreQuery->{"$.limit"};
    }
    $query->offset = $this->datastoreQuery->{"$.offset"} ?? 0;
    $query->showDbColumns = TRUE;

    return $query;
  }

  /**
   * Helper function for adding group by clauses to the given query.
   *
   * @param Drupal\dkan_common\Storage\Query $query
   *   DKAN query object we're building.
   *
   * @throws \Exception
   *   When ungrouped properties are found in the datastore query.
   */
  private function populateQueryGroupBy(Query $query): void {
    $groupings = $this->extractPropertyNames($this->datastoreQuery->{"$.groupings"} ?? []);
    if (empty($groupings)) {
      return;
    }
    $props = $this->extractPropertyNames($this->datastoreQuery->{"$.properties"} ?? []);
    if ($ungrouped = array_diff($props, $groupings)) {
      throw new \Exception('Un-grouped properties found in aggregate query: ' . implode(', ', $ungrouped));
    }
    $query->groupby = $groupings;
  }

  /**
   * Extract list of property names from an array of properties.
   *
   * Properties can be either objects, associative-arrays, or strings. If a
   * property is an object or associative-array, the property name is stored in
   * the "property" field. If a property is a string, the string itself is a
   * property name.
   *
   * @param array $props
   *   List of query properties (which can be a string, associative array, or
   *   object, see above).
   *
   * @return string[]
   *   List of extracted property names.
   *
   * @throws \Exception
   *   When an invaid property is encountered.
   */
  protected function extractPropertyNames(array $props): array {
    return array_filter(array_map(function ($prop) {
      if (is_string($prop)) {
        return $prop;
      }
      // If prop is an array, convert it to an object.
      if (is_array($prop)) {
        $prop = (object) $prop;
      }
      if (is_object($prop)) {
        if (isset($prop->property)) {
          return $prop->property;
        }
        // If this property object is an expression, ignore it.
        if (isset($prop->expression)) {
          return NULL;
        }
      }
    }, $props));
  }

  /**
   * Populate a query object with the queries from a datastore query payload.
   *
   * @param Drupal\dkan_common\Storage\Query $query
   *   DKAN generalized query object.
   */
  private function populateQueryProperties(Query $query) {
    if (empty($this->datastoreQuery->{"$.properties"})) {
      return;
    }
    foreach ($this->datastoreQuery->{"$.properties"} as $property) {
      $query->properties[] = $this->propertyConvert($property);
    }
  }

  /**
   * Convert properties from a datastore query to regular DKAN query format.
   *
   * @param mixed $property
   *   A datastore query property object, with "resource" properties.
   *
   * @return object
   *   Standardized property object with "collection" instead of "resource."
   */
  private function propertyConvert(mixed $property) {
    if (is_array($property) && isset($property["resource"])) {
      $property = (object) self::resourceRename($property);
    }
    elseif (is_array($property) && isset($property["expression"])) {
      $property["expression"] = $this->expressionConvert($property["expression"]);
      $property = (object) $property;
    }
    elseif (!is_string($property) && !is_numeric($property) && !is_array($property)) {
      throw new \Exception("Bad query property.");
    }
    return $property;
  }

  /**
   * Convert expressions from a datastore query to regular DKAN query format.
   *
   * @param array $expression
   *   An expression from a datastore query, including "resources".
   *
   * @return object
   *   Standardized expression object with "collection" instead of "resource".
   */
  private function expressionConvert(array $expression) {
    foreach ($expression["operands"] as $key => $operand) {
      $expression["operands"][$key] = $this->operandConvert($operand);
    }
    return (object) $expression;
  }

  /**
   * Convert an expression operand for expressionConvert().
   *
   * @param mixed $operand
   *   Operand from operands array.
   *
   * @return mixed
   *   Operand ready for query.
   */
  private function operandConvert(mixed $operand) {
    if (is_array($operand) && isset($operand["operator"])) {
      return $this->expressionConvert($operand);
    }
    elseif (is_numeric($operand)) {
      return $operand;
    }
    else {
      return $this->propertyConvert($operand);
    }
  }

  /**
   * Process both potential sorting direction.
   *
   * @param Drupal\dkan_common\Storage\Query $query
   *   DKAN query object we're building.
   */
  private function populateQuerySorts(Query $query) {
    if (!$this->datastoreQuery->{"$.sorts"}) {
      return;
    }
    foreach ($this->datastoreQuery->{"$.sorts"} as $sort) {
      $query->sorts[] = (object) self::resourceRename($sort);
    }
  }

  /**
   * Parse and normalize query conditions.
   *
   * @param Drupal\dkan_common\Storage\Query $query
   *   DKAN query object we're building.
   */
  private function populateQueryConditions(Query $query) {
    if (empty($this->datastoreQuery->{"$.conditions"})) {
      return;
    }
    $conditions = [];
    $primaryAlias = $this->datastoreQuery->{"$.resources[0].alias"};
    foreach ($this->datastoreQuery->{"$.conditions"} as $c) {
      $conditions[] = $this->populateQueryCondition($c, $primaryAlias);
    }
    $query->conditions = $conditions;
  }

  /**
   * Parse and normalize a single datastore query condition.
   *
   * @param mixed $condition
   *   Either a condition object or a condition group.
   *
   * @return object
   *   Valid condition object for use in a DKAN query.
   */
  private function populateQueryCondition(mixed $condition) {
    $primaryAlias = $this->datastoreQuery->{"$.resources[0].alias"};
    if (isset($condition["property"])) {
      $return = (object) [
        "collection" => $condition["resource"] ?? $primaryAlias,
        "property" => $condition["property"],
        "value" => $this->propertyConvert($condition["value"]),
      ];
      if (isset($condition["operator"])) {
        $return->operator = $condition["operator"];
      }
      return $return;
    }
    elseif (isset($condition["groupOperator"])) {
      return $this->populateQueryGroup($condition);
    }
    throw new \Exception("Invalid condition");
  }

  /**
   * Populate a single condition group.
   *
   * @param mixed $conditionGroup
   *   A conditionGroup array.
   *
   * @return object
   *   Valid condition group object for use in a DKAN query.
   */
  private function populateQueryGroup(mixed $conditionGroup) {
    foreach ($conditionGroup["conditions"] as $c) {
      $conditions[] = $this->populateQueryCondition($c);
    }
    return (object) [
      "groupOperator" => $conditionGroup["groupOperator"],
      "conditions" => $conditions,
    ];
  }

  /**
   * Helper function for converting joins to Query format.
   *
   * @param Drupal\dkan_common\Storage\Query $query
   *   DKAN query object we're building.
   */
  private function populateQueryJoins(Query $query) {
    if (empty($this->datastoreQuery->{"$.joins"}) && count($this->datastoreQuery->{"$.resources"}) <= 1) {
      return;
    }
    if (count($this->datastoreQuery->{"$.resources"}) > 1
      && count($this->datastoreQuery->{"$.joins"}) < (count($this->datastoreQuery->{"$.resources"}) - 1)) {
      throw new \Exception("Too many resources specified.");
    }
    foreach ($this->datastoreQuery->{"$.joins"} as $join) {
      $query->joins[] = $this->populateQueryJoin($join);
    }
  }

  /**
   * Populate a single join statement.
   *
   * @param object $join
   *   A join object from list of joins.
   */
  private function populateQueryJoin($join) {
    $storage = $this->storageMap[$join["resource"]];
    $queryJoin = new \stdClass();
    $queryJoin->collection = $storage->getTableName();
    $queryJoin->alias = $join["resource"];
    $queryJoin->condition = (object) $this->populateQueryCondition($join["condition"]);
    return $queryJoin;
  }

  /**
   * Rename any "resource" keys to "collection" in assoc. array.
   *
   * @param array $input
   *   Input array.
   *
   * @return array
   *   Array with renamed keys.
   */
  private static function resourceRename(array $input) {
    $return = [];
    foreach ($input as $key => $value) {
      if ($key == "resource") {
        $key = "collection";
      }
      if (is_array($value)) {
        $value = self::resourceRename($value);
      }
      $return[$key] = $value;
    }
    return $return;
  }

}
