<?php

/**
 * @file
 * Advanced aggregation bundler module.
 *
 */

/**
 * Default value of the maximum number of CSS bundles that can be generated in
 * a single request.
 */
define('ADVAGG_BUNDLER_MAX_CSS', 4);

/**
 * Default value of the maximum number of JS bundles that can be generated in
 * a single request.
 */
define('ADVAGG_BUNDLER_MAX_JS', 4);

/**
 * Default value of the last used time before the bundle is considered outdated.
 * 2 weeks in seconds.
 */
define('ADVAGG_BUNDLER_OUTDATED', 1209600);

/**
 * Default value to see if the bundler should be active or passive. If it is
 * passive, the bundler will only do analysis and not split up the aggregate.
 */
define('ADVAGG_BUNDLER_ACTIVE', TRUE);

/**
 * CSS selector limit in a single stylesheet on IE9 and below.
 */
define('SELECTOR_SPLIT_VALUE', 4095);

/**
 * Implementation of hook_menu
 */
function advagg_bundler_menu() {
  $items = array();
  $file_path = drupal_get_path('module', 'advagg_bundler');

  $items['admin/settings/advagg/bundler'] = array(
    'title' => 'Bundler',
    'description' => 'Adjust Bundler settings.',
    'page callback' => 'advagg_bundler_admin_page',
    'type' => MENU_LOCAL_TASK,
    'access arguments' => array('administer site configuration'),
    'file path' => $file_path,
    'file' => 'advagg_bundler.admin.inc',
    'weight' => 10,
  );

  return $items;
}

/**
 * Implement hook_advagg_filenames_alter.
 */
function advagg_bundler_advagg_filenames_alter(&$filenames) {
  // Get max number of sub aggregates to create.
  $max_css = variable_get('advagg_bundler_max_css', ADVAGG_BUNDLER_MAX_CSS);
  $max_js = variable_get('advagg_bundler_max_js', ADVAGG_BUNDLER_MAX_JS);

  $output = array();
  foreach ($filenames as $values) {
    // Extract values and set them.
    $filetype = $values['filetype'];
    $files = $values['files'];
    $bundle_md5 = $values['bundle_md5'];
    $cached_data_key = 'bundler_' . $bundle_md5;

    // Try cache first; cache table is cache_advagg_bundle_reuse.
    $cached_data = advagg_cached_bundle_get($cached_data_key, 'advagg_bundler_filenames_alter');
    if (!empty($cached_data)) {
      $output = array_merge($output, $cached_data);
      continue;
    }

    // If cache miss then start to do processing.
    // Set the max value based off of the filetype.
    if ($filetype == 'css') {
      $max = $max_css;
    }
    if ($filetype == 'js') {
      $max = $max_js;
    }

    // If we are only going to have one bundle then do not do any more
    // processing.
    if (empty($max) || $max == 1) {
      $output[] = $values;
      $data = array($values);
      cache_set($cached_data_key, $data, 'cache_advagg_bundle_reuse', CACHE_TEMPORARY);
      continue;
    }

    // Load up each file.
    $groupings = array();
    // Preserve the order while grouping.
    $last_group = '';
    foreach ($files as $key => $filename) {
      // Assign each file to their group.
      $group = advagg_bundler_analysis($filename);

      // Set $last_group if this is the first run of this foreach loop.
      if (empty($last_group)) {
        $last_group = $group;
      }

      if ($last_group == $group) {
        // Include this new file into the last used group.
        $groupings[$group][] = $filename;
      }
      else {
        // In order to preserve CSS/JS execution order we need to move the last
        // group to a unique name. Use the array key to make this group unique.
        $groupings[$key . ' ' . $last_group] = $groupings[$last_group];
        unset($groupings[$last_group]);
        // Place the new file into the new group and set $last_group.
        $groupings[$group][] = $filename;
        $last_group = $group;
      }
    }

    // If only one group then don't do any more processing.
    if (count($groupings) == 1) {
      $output[] = $values;
      $data = array($values);
      cache_set($cached_data_key, $data, 'cache_advagg_bundle_reuse', CACHE_TEMPORARY);
      continue;
    }

    // Make sure we didn't go over the max; if we did merge the smallest bundles
    // together.
    advagg_bundler_merge($groupings, $max, $filetype);

    // If only one group then don't do any more processing. The merge algorithm
    // could have reduce the groupings down to one.
    if (count($groupings) == 1) {
      $output[] = $values;
      $data = array($values);
      cache_set($cached_data_key, $data, 'cache_advagg_bundle_reuse', CACHE_TEMPORARY);
      continue;
    }

    // Set groups.
    $data = array();
    foreach ($groupings as $bundle) {
      $values['files'] = $bundle;
      $values['bundle_md5'] = md5(implode('', $bundle));
      $data[] = $values;
      $output[] = $values;
    }
    cache_set($cached_data_key, $data, 'cache_advagg_bundle_reuse', CACHE_TEMPORARY);
  }

  // Return groupings.
  if (variable_get('advagg_bundler_active', ADVAGG_BUNDLER_ACTIVE)) {
    $filenames = $output;
  }
}

/**
 * Given a filename return a bundle key.
 *
 * @param $filename
 *   filename
 * @param $force
 *   bypass the cache and get a fresh version of the analysis.
 * @return
 *   string to be used for the grouping key.
 */
function advagg_bundler_analysis($filename = '', $force = FALSE) {
  // Cache query in a static.
  static $analysis = array();
  if (empty($analysis)) {
    // See if we have a cached version of this.
    $count = db_result(db_query("SELECT COUNT(*) FROM {advagg_bundles} WHERE root=1"));
    $cache = cache_get('advagg_bundler_analysis:' . $count);
    if (empty($cache->data) || $force) {
      // "Magic Query"; only needs to run once.
      // Return a count of how many root bundles all files are used in. Count is
      // padded with eight zeros so the count can be key sorted as a string
      // without worrying about it getting put in the wrong order.
      // Return the bundle_md5's value; we need something more unique than count
      // when grouping together.
      // Return the filename. Used for lookup.
      // We join the advagg bundles and files together making sure to only use
      // root bundles that have been used in the last 2 weeks. This prevents an
      // old site structure from influencing new bundles.
      // Grouping by the filename gives us the count and makes it so we don't
      // return a lot of rows;
      $result = db_query("
        SELECT
          count,
          bundle_md5,
          filename
        FROM (
          SELECT
            LPAD(COUNT(*), 8, '00000000') AS count,
            bundle_md5,
            filename_md5
          FROM
            {advagg_bundles}
          WHERE
            root = 1
            AND
            timestamp > %d
          GROUP BY
            filename_md5) AS ab
        INNER JOIN {advagg_files} AS af USING ( filename_md5 )
      ", time() - variable_get('advagg_bundler_outdated', ADVAGG_BUNDLER_OUTDATED));

      // Save query into a static array with the filename as the key.
      while ($row = db_fetch_array($result)) {
        $analysis[$row['filename']] = $row['count'] . ' ' . $row['bundle_md5'];
      }

      // Invoke hook_advagg_bundler_analysis_alter() to give installed modules a
      // chance to alter the analysis array.
      drupal_alter('advagg_bundler_analysis', $analysis);

      // Save results to the cache.
      cache_set('advagg_bundler_analysis:' . $count, $analysis, 'cache', CACHE_TEMPORARY);
    }
    else {
      $analysis = $cache->data;
    }
  }

  // If no filename is given pass back then entire query results.
  if (empty($filename)) {
    return $analysis;
  }

  // Return a key to be used in groupings.
  if (!empty($analysis[$filename])) {
    return $analysis[$filename];
  }

  // We need to return a value that can be used as an array key if the query
  // didn't give us anything.
  return 0;
}

/**
 * Merge bundles together if too many where created.
 *
 * This preserves the order.
 *
 * @param $groupings
 *   array of requested groups
 * @param $max
 *   max number of grouping
 */
function advagg_bundler_merge(&$groupings, $max, $filetype) {
  $group_count = count($groupings);

  if (!empty($max)) {
    // Keep going till array has been merged to the desired size.
    while ($group_count > $max) {
      // Get array sizes.
      // Counts the number of files that are placed into each bundle.
      $counts = array();
      foreach ($groupings as $key => $values) {
        $counts[$key] = count($values);
      }

      // Create mapping.
      // Calculates the file count of potential merges. It only merges with
      // neighbors in order to preserve execution order.
      $map = array();
      $prev_key = '';
      foreach ($counts as $key => $val) {
        // First run of the foreach loop; populate prev key/values and continue.
        // We can't merge with the previous group in this case.
        if (empty($prev_key)) {
          $prev_key = $key;
          $prev_val = $val;
          continue;
        }

        // Array key ($prev_val + $val) is the file count of this new group if
        // these 2 groups ($prev_key, $key) where to be merged together.
        $map[] = array(
          ($prev_val + $val) => array($prev_key, $key),
        );
        $prev_key = $key;
        $prev_val = $val;
      }

      // Get best merge candidate.
      // We are looking for the smallest file count. $min is populated with a
      // large number (15 bits) so it won't be selected in this process.
      $min = 32767;
      foreach ($map as $v) {
        foreach ($v as $key => $values) {
          $min = min($min, $key);
          // If the min value (number of files in the proposed merged bundle) is
          // the same as the key, then get the 2 bundle keys that generated this
          // new min value.
          if ($min == $key) {
            list($first, $last) = $values;
          }
        }
      }

      // Create the new merged set.
      $a = $groupings[$first];
      $b = $groupings[$last];
      $new_set = array_merge($a, $b);

      // Rebuild the array with the new set in the correct place.
      $new_groupings = array();
      foreach ($groupings as $key => $files) {
        if ($key == $first) {
          $new_groupings[$first . ' ' . $last] = $new_set;
        }
        elseif ($key != $last) {
          $new_groupings[$key] = $files;
        }
      }
      $groupings = $new_groupings;
      $group_count--;
    }
  }

  // Make sure there isn't a group that has all files that don't exist or empty.
  $bad_groups = array();
  $counts = array();
  foreach ($groupings as $key => $group) {
    $missing_counter = 0;
    $counts[$key] = count($group);
    foreach ($group as $i => $file) {
      advagg_clearstatcache(TRUE, $file);
      if (!file_exists($file) || filesize($file) == 0) {
        $missing_counter++;
      }
    }

    // If all files are missing/empty in this group then it is a bad set.
    if ($missing_counter == count($group)) {
      $bad_groups[$key] = TRUE;
    }
  }

  // Add the bad groups to the smallest grouping in this set.
  if (!empty($bad_groups)) {
    $merge_candidate_key = '';
    $merge_candidate_count = 32767;
    $bad_group = array();
    foreach ($groupings as $key => $group) {
      if (isset($bad_groups[$key])) {
        // Merge all bad groups into one.
        $bad_group = array_merge($bad_group, $group);

        // Delete the bad group from the master set.
        unset($groupings[$key]);
        continue;
      }

      // Find the smallest good grouping.
      $min = min($merge_candidate_count, count($group));
      if ($min < $merge_candidate_count) {
        $merge_candidate_key = $key;
        $merge_candidate_count = $min;
      }
    }

    // Move the bad files into the smallest good group.
    $new_set = $groupings[$merge_candidate_key];
    $new_set = array_merge($new_set, $bad_group);
    $groupings[$merge_candidate_key] = $new_set;
  }

  // Prevent CSS selectors exceeding 4095 due to limits with IE9 and below.
  if ($filetype == 'css') {

    // Check each group to see if it exceeds the selector limit.
    do {
      $groupings_edited = FALSE;
      foreach ($groupings as $key => $group) {

        // Restart the selector limit check if the grouping was edited.
        if ($groupings_edited) {
          break;
        }

        $group_selector_counter = 0;

        $selector_counts = advagg_bundler_get_css_selector_count($group);

        for ($i = 0; $i < count($group) && !$groupings_edited; $i++) {

          $selector_count = isset($selector_counts[$group[$i]]) ? $selector_counts[$group[$i]] : 0;

          if ($group_selector_counter + $selector_count > SELECTOR_SPLIT_VALUE) {
            $groupings_edited = TRUE;

            // Divide the group.
            $first_group = array_splice($group, 0, $i);
            $second_group = array_splice($group, 0);

            // Rebuild the array with the new set in the correct place.
            $new_groupings = array();
            foreach ($groupings as $k => $files) {
              if ($k == $key) {
                $new_groupings[$k . '_1'] = $first_group;
                $new_groupings[$k . '_2'] = $second_group;
              }
              else {
                $new_groupings[$k] = $files;
              }
            }
            $groupings = $new_groupings;
          }
          else {
            $group_selector_counter += $selector_count;
          }
        }
      }
    } while ($groupings_edited);
  }
}

/**
 * Gets the selector count of the provided files.
 *
 * @param array $files
 *   Array of files to use.
 *
 * @return array
 *   The selector counts of each file.
 */
function advagg_bundler_get_css_selector_count($files) {
  $results = array();
  $placeholders = db_placeholders($files);
  $result  = db_query("SELECT filename, selector_count, timestamp FROM {advagg_bundler_selector_count} WHERE filename IN ($placeholders)", $files);

  while ($row = db_fetch_array($result)) {
    $modified = 0;
    if (is_readable($row['filename'])) {
      $modified = filemtime($row['filename']);
    }
    if ($modified > $row['timestamp']) {
      $css = advagg_build_css_bundle(array($row['filename']), TRUE);

      // Get the number of selectors.
      // http://stackoverflow.com/questions/12567000/regex-matching-for-counting-css-selectors/12567381#12567381
      $selector_count = preg_match_all('/\{.+?\}|,/s', $css, $matched);

      db_query("UPDATE {advagg_bundler_selector_count} SET timestamp = %d, selector_count = %d WHERE filename LIKE '%s'", $modified, $selector_count, $row['filename']);

      $results[$row['filename']] = $selector_count;
    }
    else {
      $results[$row['filename']] = $row['selector_count'];
    }
  }

  foreach ($files as $file) {
    if (!isset($results[$file]) && file_exists($file)) {
      $css = advagg_build_css_bundle(array($file), TRUE);
      $selector_count = preg_match_all('/\{.+?\}|,/s', $css, $matched);

      db_query("INSERT INTO {advagg_bundler_selector_count} VALUES('%s', '%s', %d, %d)", $file, md5($file), $selector_count, filemtime($file));

      $results[$file] = $selector_count;
    }
  }

  return $results;
}
