<?php

/**
 * @file
 * Enables Rules based product sell price calculation for dynamic product pricing.
 */

/**
 * Implements hook_hook_info().
 */
function commerce_product_pricing_hook_info() {
  $hooks = array(
    'commerce_product_valid_pre_calculation_rule' => array(
      'group' => 'commerce',
    ),
    'commerce_product_valid_pre_calculation_product' => array(
      'group' => 'commerce',
    ),
    'commerce_product_calculate_sell_price_line_item_alter' => array(
      'group' => 'commerce',
    ),
  );

  return $hooks;
}

/**
 * Implements hook_commerce_price_field_calculation_options().
 *
 * To accommodate dynamic sell price calculation on the display level, we depend
 * on display formatter settings to alert the module when to calculate a price.
 * However, by default all price fields are set to show the original price as
 * loaded with no option to change this. This module needs to add its own option
 * to the list so it can know which prices should be calculated on display.
 *
 * @see commerce_product_pricing_commerce_price_field_formatter_prepare_view()
 */
function commerce_product_pricing_commerce_price_field_calculation_options($field, $instance, $view_mode) {
  // If this is a single value purchase price field attached to a product...
  if (($instance['entity_type'] == 'commerce_product' || $field['entity_types'] == array('commerce_product')) &&
    $field['field_name'] == 'commerce_price' && $field['cardinality'] == 1) {
    return array('calculated_sell_price' => t('Display the calculated sell price for the current user.'));
  }
}

/**
 * Implements hook_commerce_price_field_formatter_prepare_view().
 *
 * It isn't until the point of display that we know whether a particular price
 * field should be altered to display the current user's purchase price of the
 * product. Therefore, instead of trying to calculate dynamic prices on load,
 * we calculate them prior to display but at the point where we know the full
 * context of the display, including the display formatter settings for the
 * pertinent view mode.
 *
 * The hook is invoked before a price field is formatted, so this implementation
 * lets us swap in the calculated sell price of a product for a given point of
 * display. The way it calculates the price is by creating a pseudo line item
 * for the current product that is passed to Rules for transformation. Rule
 * configurations may then use actions to set and alter the unit price of the
 * line item, which, being an object, is passed by reference through all the
 * various actions. Upon completion of the Rules execution, the unit price data
 * is then swapped in for the data of the current field for display.
 */
function commerce_product_pricing_commerce_price_field_formatter_prepare_view($entity_type, $entities, $field, $instances, $langcode, &$items, $displays) {
  // If this is a single value purchase price field attached to a product...
  if ($entity_type == 'commerce_product' && $field['field_name'] == 'commerce_price' && $field['cardinality'] == 1) {
    // Prepare the items for each entity passed in.
    foreach ($entities as $product_id => $product) {
      // If this price should be calculated...
      if (!empty($displays[$product_id]['settings']['calculation']) &&
        $displays[$product_id]['settings']['calculation'] == 'calculated_sell_price') {
        // If this price has already been calculated, reset it to its original
        // value so it can be recalculated afresh in the current context.
        if (isset($items[$product_id][0]['original'])) {
          $original = $items[$product_id][0]['original'];
          $items[$product_id] = array(0 => $original);

          // Reset the price field value on the product object used to perform
          // the calculation.
          foreach ($product->commerce_price as $langcode => $value) {
            $product->commerce_price[$langcode] = $items[$product_id];
          }
        }
        else {
          // Save the original value for use in subsequent calculations.
          $original = isset($items[$product_id][0]) ? $items[$product_id][0] : NULL;
        }

        // Replace the data being displayed with data from a calculated price.
        $items[$product_id] = array();
        $items[$product_id][0] = commerce_product_calculate_sell_price($product);
        $items[$product_id][0]['original'] = $original;
      }
    }
  }
}

/**
 * Returns the calculated sell price for the given product.
 *
 * @param $product
 *   The product whose sell price will be calculated.
 * @param $precalc
 *   Boolean indicating whether or not the pre-calculated sell price from the
 *     database should be requested before calculating it anew.
 *
 * @return
 *   A price field data array as returned by entity_metadata_wrapper().
 */
function commerce_product_calculate_sell_price($product, $precalc = FALSE) {
  // First create a pseudo product line item that we will pass to Rules.
  $line_item = commerce_product_line_item_new($product);

  // Allow modules to prepare this as necessary.
  drupal_alter('commerce_product_calculate_sell_price_line_item', $line_item);

  // Attempt to fetch a database stored price if specified.
  if ($precalc) {
    $module_key = commerce_product_pre_calculation_key();

    $result = db_select('commerce_calculated_price')
      ->fields('commerce_calculated_price', array('amount', 'currency_code', 'data'))
      ->condition('module', 'commerce_product_pricing')
      ->condition('module_key', $module_key)
      ->condition('entity_type', 'commerce_product')
      ->condition('entity_id', $product->product_id)
      ->condition('field_name', 'commerce_price')
      ->execute()
      ->fetchObject();

    // If a pre-calculated price was found...
    if (!empty($result)) {
      // Wrap the line item, swap in the price, and return it.
      $wrapper = entity_metadata_wrapper('commerce_line_item', $line_item);

      $wrapper->commerce_unit_price->amount = $result->amount;
      $wrapper->commerce_unit_price->currency_code = $result->currency_code;

      // Unserialize the saved prices data array and initialize to an empty
      // array if the column was empty.
      $result->data = unserialize($result->data);
      $wrapper->commerce_unit_price->data = !empty($result->data) ? $result->data : array();

      return $wrapper->commerce_unit_price->value();
    }
  }

  // Pass the line item to Rules.
  rules_invoke_event('commerce_product_calculate_sell_price', $line_item);

  return entity_metadata_wrapper('commerce_line_item', $line_item)->commerce_unit_price->value();
}

/**
 * Generates a price pre-calculation module key indicating which pricing Rules
 *   currently apply.
 */
function commerce_product_pre_calculation_key() {
  // Load the sell price calculation event.
  $event = rules_get_cache('event_commerce_product_calculate_sell_price');

  // If there are no rule configurations, use an empty key.
  if (empty($event)) {
    return '';
  }

  // Build an array of the names of all rule configurations that qualify for
  // dynamic pre-calculation.
  $rule_names = array();

  $state = new RulesState();

  foreach ($event as $rule) {
    // Only add Rules with conditions that evaluate to TRUE.
    if (count($rule->conditions()) > 0 &&
      $rule->conditionContainer()->evaluate($state)) {
      $rule_names[] = $rule->name;
    }
  }

  // If no valid Rules were found, return an empty string.
  if (empty($rule_names)) {
    return '';
  }

  // Otherwise sort to ensure the names are in alphabetical order and return the
  // imploded module key.
  sort($rule_names);

  return implode('|', $rule_names);
}

/**
 * Returns an array of rule keys used for pre-calculating product sell prices.
 *
 * Rule keys represent every possible combination of rules that might alter any
 * given product sell price. If no valid rules exist, an empty array is returned.
 */
function commerce_product_pre_calculation_rule_keys() {
  static $rule_keys = NULL;

  if (is_null($rule_keys)) {
    // Load the sell price calculation event.
    $event = rules_get_cache('event_commerce_product_calculate_sell_price');

    // Build an array of the names of all rule configurations that qualify for
    // dynamic pre-calculation.
    $rule_names = array();

    foreach ($event as $rule) {
      if (commerce_product_valid_pre_calculation_rule($rule)) {
        $rule_names[] = $rule->name;
      }
    }

    // Sort to ensure the names are always in alphabetical order.
    sort($rule_names);

    // Using the array of names, generate an array that contains keys for every
    // possible combination of these Rules applying (i.e. conditions all passing).
    $rule_keys = array();

    // First find the maximum number of combinations as a power of two.
    $max = pow(2, count($rule_names));

    // Loop through each combination expressed as an integer.
    for ($i = 0; $i < $max; $i++) {
      // Convert the integer to a string binary representation, reverse it (so the
      // first bit is on the left instead of right), and split it into an array
      // with each bit as its own value.
      $bits = str_split(strrev(sprintf('%0' . count($rule_names) . 'b', $i)));

      // Create a key of underscore delimited Rule IDs by assuming a 1 means the
      // Rule ID in the $rule_ids array with the same key as the bit's position in
      // the string should be assumed to have applied.
      $key = implode('|', array_intersect_key($rule_names, array_intersect($bits, array('1'))));

      $rule_keys[] = $key;
    }
  }

  return $rule_keys;
}

/**
 * Pre-calculates sell prices for qualifying products based on valid rule
 *   configurations on the "Calculating product sell price" event.
 *
 * @param $from
 *   For calculating prices for a limited number of products at a time, specify
 *     the range's "from" amount.
 * @param $count
 *   For calculating prices for a limited number of products at a time, specify
 *     the range's "count" amount.
 *
 * @return
 *   The number of pre-calculated prices created.
 */
function commerce_product_pre_calculate_sell_prices($from = NULL, $count = 1) {
  $total = 0;

  // Load the sell price calculation event.
  $event = rules_get_cache('event_commerce_product_calculate_sell_price');

  // If there are no rule configurations, leave without further processing.
  if (empty($event)) {
    return array();
  }

  // If a range was specified, query just those products...
  if (!is_null($from)) {
    $query = db_query_range("SELECT product_id FROM {commerce_product}", $from, $count);
  }
  else {
    // Otherwise load all products.
    $query = db_query("SELECT product_id FROM {commerce_product}");
  }

  while ($product_id = $query->fetchField()) {
    $product = commerce_product_load($product_id);

    // If the product is valid for pre-calculation...
    if (commerce_product_valid_pre_calculation_product($product)) {
      // For each rule key (i.e. set of applicable rule configurations)...
      foreach (commerce_product_pre_calculation_rule_keys() as $key) {
        // Build a product line item and Rules state object.
        $line_item = commerce_product_line_item_new($product);

        $state = new RulesState();
        $vars = $event->parameterInfo(TRUE);
        $state->addVariable('commerce_line_item', $line_item, $vars['commerce_line_item']);

        // For each Rule signified by the current key...
        foreach (explode('|', $key) as $name) {
          // Load the Rule and "fire" it, evaluating its actions without doing
          // any condition evaluation.
          if ($rule = rules_config_load($name)) {
            $rule->fire($state);
          }
        }

        // Also fire any Rules that weren't included in the key because they
        // don't have any conditions.
        foreach ($event as $rule) {
         if (count($rule->conditions()) == 0) {
            $rule->fire($state);
          }
        }

        // Build the record of the pre-calculated price and write it.
        $wrapper = entity_metadata_wrapper('commerce_line_item', $line_item);

        if (!is_null($wrapper->commerce_unit_price->value())) {
          $record = array(
            'module' => 'commerce_product_pricing',
            'module_key' => $key,
            'entity_type' => 'commerce_product',
            'entity_id' => $product_id,
            'field_name' => 'commerce_price',
            'language' => !empty($product->language) ? $product->language : LANGUAGE_NONE,
            'delta' => 0,
            'amount' => round($wrapper->commerce_unit_price->amount->value()),
            'currency_code' => $wrapper->commerce_unit_price->currency_code->value(),
            'data' => $wrapper->commerce_unit_price->data->value(),
            'created' => time(),
          );

          // Save the price and increment the total if successful.
          if (drupal_write_record('commerce_calculated_price', $record) == SAVED_NEW) {
            $total++;
          }
        }
      }
    }
  }

  return $total;
}

/**
 * Determines if a given rule configuration meets the requirements for price
 *   pre-calculation.
 *
 * @param $rule
 *   A rule configuration belonging to the commerce_product_calculate_sell_price
 *     event.
 * @param $limit_validation
 *   A boolean indicating whether or not the validation check should limit
 *     itself to the conditions in this function, effectively skipping the
 *     invocation of hook_commerce_product_valid_pre_calculation_rule().
 *
 * @return
 *   TRUE or FALSE indicating whether or not the rule configuration is valid.
 */
function commerce_product_valid_pre_calculation_rule($rule, $limit_validation = FALSE) {
  // If a rule configuration doesn't have any conditions, it doesn't need to
  // unique consideration in pre-calculation, as its actions will always apply.
  if (count($rule->conditions()) == 0) {
    return FALSE;
  }

  // Inspect each condition on the rule configuration. This likely needs to be
  // recursive for conditions in nested operator groups.
  foreach ($rule->conditions() as $condition) {
    // Look for line item usage in any selector in the condition settings.
    foreach ($condition->settings as $key => $value) {
      if (substr($key, -7) == ':select') {
        // If the selector references either line-item or line-item-unchanged,
        // the Rule is not valid for price pre-calculation.
        if (strpos($value, 'line-item') === 0) {
          return FALSE;
        }
      }
    }
  }

  // Allow other modules to invalidate this rule configuration.
  if (!$limit_validation) {
    if (in_array(FALSE, module_invoke_all('commerce_product_valid_pre_calculation_rule', $rule))) {
      return FALSE;
    }
  }

  return TRUE;
}

/**
 * Determines if a given product should be considered for price pre-calculation.
 *
 * @param $product
 *   The product being considered for sell price pre-calculation.
 *
 * @return
 *   TRUE or FALSE indicating whether or not the product is valid.
 */
function commerce_product_valid_pre_calculation_product($product) {
  // Allow other modules to invalidate this product.
  if (in_array(FALSE, module_invoke_all('commerce_product_valid_pre_calculation_product', $product))) {
    return FALSE;
  }

  return TRUE;
}

/**
 * Sets a batch operation to pre-calculate product sell prices.
 *
 * This function prepares and sets a batch to pre-calculate product sell prices
 * based on the number of variations for each price and the number of products
 * to calculate. It does not call batch_process(), so if you are not calling
 * this function from a form submit handler, you must process yourself.
 */
function commerce_product_batch_pre_calculate_sell_prices() {
  // Create the batch array.
  $batch = array(
    'title' => t('Pre-calculating product sell prices'),
    'operations' => array(),
    'finished' => 'commerce_product_batch_pre_calculate_sell_prices_finished',
  );

  // Add operations based on the number of rule combinations and number of
  // products to be pre-calculated.
  $rule_keys = commerce_product_pre_calculation_rule_keys();

  // Count the number of products.
  $product_count = db_select('commerce_product', 'cp')
    ->fields('cp', array('product_id'))
    ->countQuery()
    ->execute()
    ->fetchField();

  // Create a "step" value based on the number of rule keys, i.e. how many
  // products to calculate at a time. This will roughly limit the number of
  // queries to 500 on any given batch operations.
  if (count($rule_keys) > 500) {
    $step = 1;
  }
  else {
    $step = min(round(500 / count($rule_keys)) + 1, $product_count);
  }

  // Add batch operations to pre-calculate every price.
  for ($i = 0; $i < $product_count; $i += $step) {
    // Ensure the maximum step doesn't go over the total number of rows for
    // accurate reporting later.
    if ($i + $step > $product_count) {
      $step = $product_count - $i;
    }

    $batch['operations'][] = array('_commerce_product_batch_pre_calculate_sell_prices', array($i, $step));
  }

  batch_set($batch);
}

/**
 * Calculates pre-calculation using the given range values and updates the batch
 *   with processing information.
 */
function _commerce_product_batch_pre_calculate_sell_prices($from, $count, &$context) {
  // Keep track of the total number of products covered and the total number of
  // prices created in the results array.
  if (empty($context['results'])) {
    $context['results'] = array(0, 0);
  }

  // Increment the number of products processed.
  $context['results'][0] += $count;

  // Increment the number of actual prices created.
  $context['results'][1] += commerce_product_pre_calculate_sell_prices($from, $count);
}

/**
 * Displays a message upon completion of a batched sell price pre-calculation.
 */
function commerce_product_batch_pre_calculate_sell_prices_finished($success, $results, $operations) {
  if ($success) {
    $message = format_plural($results[0], "Sell prices pre-calculated for 1 product resulting in @total prices created.", "Sell prices pre-calculated for @count products resulting in @total prices created.", array('@total' => $results[1]));
  }
  else {
    $message = t('Batch pre-calculation finished with an error.');
  }

  drupal_set_message($message);
}
