<?php

/**
 * @file
 * Handles compiling of .less files.
 *
 * The theme system allows for nearly all output of the Drupal system to be
 * customized by user themes.
 */

define('LESS_PERMISSION', 'administer less');

/**
 * Implements hook_hook_info().
 */
function less_hook_info() {
  $hooks = array();
  
  $hooks['form_system_theme_settings_alter'] = array(
    'group' => 'theme',
  );
  
  $hooks['wysiwyg_editor_settings_alter'] = array(
    'group' => 'wysiwyg',
  );
  $hooks['form_wysiwyg_profile_form_alter'] = array(
    'group' => 'wysiwyg',
  );
  
  $hooks['cron_queue_info'] = array(
    'group' => 'cron',
  );
  $hooks['cron'] = array(
    'group' => 'cron',
  );
  
  return $hooks;
}

/**
 * Implements hook_menu().
 */
function less_menu() {
  $items = array();

  $items['admin/config/development/less'] = array(
    'title' => 'LESS settings',
    'description' => 'Administer LESS settings',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('less_settings_form'),
    'access arguments' => array(LESS_PERMISSION),
    'file' => 'less.admin.inc',
    'type' => MENU_NORMAL_ITEM,
  );

  $items['admin/config/development/less/settings'] = array(
    'title' => $items['admin/config/development/less']['title'],
    'type' => MENU_DEFAULT_LOCAL_TASK,
  );

  $items['ajax/less/watch'] = array(
    'title' => 'LESS watch callback',
    'type' => MENU_CALLBACK,
    'page callback' => '_less_watch',
    'access callback' => TRUE,
    'delivery callback' => 'drupal_json_output',
  );

  return $items;
}

/**
 * Implements hook_permission().
 */
function less_permission() {
  return array(
    LESS_PERMISSION => array(
      'title' => t('Administer LESS'),
      'description' => t('Access the LESS settings page and view debug messages.'),
    ),
  );
}

/**
 * Implements hook_element_info_alter().
 */
function less_element_info_alter(&$type) {
  
  // Prepend to the list of #pre_render functions so it runs first. 
  array_unshift($type['styles']['#pre_render'], '_less_pre_render');
  
  if (variable_get('less_devel', FALSE)) {
    // Must run after drupal_pre_render_styles() to attach any attributes.
    array_push($type['styles']['#pre_render'], '_less_attach_src');
  }
}

/**
 * Add original .less file path as 'src' attribute to <link />.
 */
function _less_attach_src($styles) {
  
  foreach (element_children($styles) as $key) {
    
    // If its a <link />, then most likely its a compiled .less file.
    if ($styles[$key]['#tag'] == 'link') {
      
      // Hashes are generated based on the URL without the query portion.
      $file_url = drupal_substr($styles[$key]['#attributes']['href'], 0, mb_strpos($styles[$key]['#attributes']['href'], '?'));
      
      //If we have a match, it means it is a compiled .less file.
      if ($cache = cache_get('less:watch:' . drupal_hash_base64($file_url))) {
        
        // Some inspectors allow 'src' attribute to open from a click.
        $styles[$key]['#attributes']['src'] = $cache->data['data'];
      }
    }
  }
  
  return $styles;
}

/**
 * Processes .less files
 */
function _less_pre_render($styles) {
  
  $less_devel = variable_get('less_devel', FALSE);
  $less_dir = _less_get_dir();
  
  if ($less_devel) {
    
    if (variable_get('less_watch', TRUE)) {
      drupal_add_js(drupal_get_path('module', 'less') . '/less.watch.js');
    }
    
    // Warn users once every hour that less is checking for file modifications.
    if (user_access(LESS_PERMISSION) && flood_is_allowed('less_devel_warning', 1)) {
      flood_register_event('less_devel_warning');
      
      $message = 'LESS files are being checked for modifications on every request. Remember to <a href="@url">turn off</a> this feature on production websites.';
      $message_vars = array("@url" => url('admin/config/development/less'));
      drupal_set_message(t($message, $message_vars), 'status');
    }
  }
  
  $less_path = 'public://less/' . $less_dir;
  
  foreach ($styles['#items'] as $key => $info) {
    $input_file = $info['data'];
    if (drupal_substr($input_file, -5) == '.less') {
      
      $file_uri = file_uri_target($input_file);
      $css_path = $less_path . '/' . dirname($file_uri ? $file_uri : $input_file);
      
      // Ensure the destination directory exists.
      if (!_less_ensure_directory($css_path)) {
        // Cancel the for each loop when directory doesn't exist.
        break;
      }

      $output_file = $css_path . '/' . basename($input_file, '.less');
      // correct file names of files not following the .css.less naming convention
      if (drupal_substr($output_file, -4) != '.css') {
        $output_file .= '.css';
      }
      
      $less_settings = less_get_settings(_less_file_owner($input_file));
      
      if (!empty($info['less'])) {
        $less_settings = drupal_array_merge_deep($less_settings, (array) $info['less']);
      }
      
      // array_multisort() the data so that the hash returns the same hash regardless order of data.
      array_multisort($less_settings);
      
      // json_encode() is used because serialize() throws an error with lambda functions.
      $output_file = substr_replace($output_file, drupal_hash_base64(json_encode($less_settings)) . '.css', -3);
      
      $rebuild = FALSE;
      
      // Set $rebuild if this file or its children have been modified.
      if ($less_devel && is_file($output_file)) {
        $output_file_mtime = filemtime($output_file);
        
        if ($less_file_cache = cache_get('less:devel:' . drupal_hash_base64($input_file))) {
          
          // Iterate over each file and check if there are any changes.
          foreach ($less_file_cache->data as $filepath => $filemtime) {
            
            // Only rebuild if there has been a change to a file.
            if (filemtime($filepath) > $filemtime) {
              $rebuild = TRUE;
              break;
            }
          }
        }
        else {
          
          // No cache data, force a rebuild for later comparison.
          $rebuild = TRUE;
        }
      }
      
      // $output_file doesn't exist or is flagged for rebuild.
      if ((!is_file($output_file) || $rebuild) && _less_inc()) {
        
        $less = new lessc();
        
        if (method_exists($less, 'registerFunction') && is_array($less_settings['functions'])) {
          foreach ($less_settings['functions'] as $funcion => $callback) {
            $less->registerFunction($funcion, $callback);
          }
        }
        
        if (method_exists($less, 'setVariables')) {
          $less->setVariables($less_settings['variables']);
        }
        
        $cache = NULL;
        $output_data = NULL;
        
        // Since lessphp is a php class, this will capture errors for output.
        try {
          if ($less_devel) {
            $less->setPreserveComments(true);
            $cache = $less->cachedCompile($input_file);
            cache_set('less:devel:' . drupal_hash_base64($input_file), $cache['files']);
            $output_data = $cache['compiled'];
          }
          else {
            $output_data = $less->compileFile($input_file);
          }
          
        }
        catch (Exception $e) {
          
          $message = 'LESS error: @message, %input_file';
          $message_vars = array('@message' => $e->getMessage(), '%input_file' => $input_file);
          
          watchdog('LESS', $message, $message_vars, WATCHDOG_ERROR);
          
          if (user_access(LESS_PERMISSION)) {
            drupal_set_message(t($message, $message_vars), 'error');
          }
        }
        
        if (isset($output_data)) {
          
          // Fix paths for images as .css is in different location.
          $output_data = _less_rewrite_paths($input_file, $output_data);
          
          file_unmanaged_save_data($output_data, $output_file, FILE_EXISTS_REPLACE);
        }

      }

      if (is_file($output_file)) {
        $styles['#items'][$key]['data'] = $output_file;
        
        if ($less_devel) {
          
          $less_watch_cache = $styles['#items'][$key];
          
          $less_watch_cache['less'] = $less_settings;
          $less_watch_cache['data'] = $input_file;
          $less_watch_cache['output_file'] = $output_file;

          cache_set('less:watch:' . drupal_hash_base64(file_create_url($output_file)), $less_watch_cache);
          
          // 'preprocess' being FALSE generates a discreet <link /> rather than an @import.
          $styles['#items'][$key]['preprocess'] = FALSE;
        }
      }

    }

  }
  
  return $styles;
}

/**
 * Implements hook_admin_menu_cache_info().
 */
function less_admin_menu_cache_info() {
  $caches['less'] = array(
    'title' => t('LESS files'),
    'callback' => 'less_flush_caches',
  );
  return $caches;
}

/**
 * Implements hook_flush_caches().
 *
 * Flushes compiled LESS files during cache flush, except during cron.
 *
 * @return An array of cache table names.
 */
function less_flush_caches() {
  if (!drupal_static('less_cron')) {
    
    // rebuild the less files directory.
    _less_get_dir(TRUE);
    cache_clear_all('less:', 'cache', TRUE);
  }
  return array();
}

/**
 * Helper function to create a new less dir.
 */
function _less_get_dir($rebuild = FALSE) {
  $less_dir = variable_get('less_dir');
  
  // If drupal variable 'less_dir' is not set, empty or manually reset then
  // generate a new unique id and save it.
  if ($rebuild || empty($less_dir)) {
    
    // Set the less directory variable
    variable_set('less_dir', drupal_hash_base64(uniqid('', TRUE)));
  }
  
  return variable_get('less_dir');
}

/**
 * Finds and loads the lessphp library
 */
function _less_inc() {
  static $loaded = NULL;
  
  if (!isset($loaded)) {
    
    // Locations to check for lessphp, by order of preference.
    $include_locations = array();
    
    // Composer created path.
    $include_locations[] = dirname(__FILE__) . '/vendor/autoload.php';
    
    // Ensure libraries module is loaded.
    module_load_include('module', 'libraries');
    
    if (function_exists('libraries_get_path')) {
      // Add libraries supported path.
      $include_locations[] = libraries_get_path('lessphp') . '/lessc.inc.php';
    }
    
    // Add legacy path as final possible location.
    $include_locations[] = dirname(__FILE__) . '/lessphp/lessc.inc.php';
    
    foreach ($include_locations as $include_location) {
      if (is_file($include_location)) {
        require_once $include_location;
        break;
      }
    }
    
    $loaded = class_exists('lessc', TRUE) && version_compare(lessc::$VERSION, 'v0.3.7', '>=');
  }

  return $loaded;
}

/**
 * Copied functionality from drupal_build_css_cache() for our own purposes.
 * 
 * This function processes $contents and rewrites relative paths to be absolute
 * from web root. This is mainly used to ensure that compiled .less files still
 * reference images at their original paths.
 */
function _less_rewrite_paths($input_filepath, $contents) {
  $output = '';
  
  // Build the base URL of this CSS file: start with the full URL.
  $css_base_url = file_create_url($input_filepath);
  // Move to the parent.
  $css_base_url = substr($css_base_url, 0, strrpos($css_base_url, '/'));
  // Simplify to a relative URL if the stylesheet URL starts with the
  // base URL of the website.
  if (substr($css_base_url, 0, strlen($GLOBALS['base_root'])) == $GLOBALS['base_root']) {
    $css_base_url = substr($css_base_url, strlen($GLOBALS['base_root']));
  }
  
  _drupal_build_css_path(NULL, $css_base_url . '/');
  // Anchor all paths in the CSS with its base URL, ignoring external and absolute paths.
  $output .= preg_replace_callback('/url\(\s*[\'"]?(?![a-z]+:|\/+)([^\'")]+)[\'"]?\s*\)/i', '_drupal_build_css_path', $contents);
  
  return $output;
}

/**
 * This keeps track of which modules and themes own which .less files, and any
 * variable defaults those system items define.
 * 
 * Only tracks .less files that are added through .info files.
 */
function _less_registry() {
  $static_stylesheets = &drupal_static('less_stylesheets');
  $static_defaults = &drupal_static('less_defaults');
  
  if (!isset($static_stylesheets) || !isset($static_defaults)) {
    
    if (($cache_stylesheets = cache_get('less:stylesheets')) && ($cache_defaults = cache_get('less:defaults'))) {
      $static_stylesheets = $cache_stylesheets->data;
      $static_defaults = $cache_defaults->data;
    }
    else {
      
      $system_types = array(
        'module_enabled',
        'theme',
      );
      
      foreach ($system_types as $system_type) {
        $system_items = system_list($system_type);
        
        foreach ($system_items as $system_item_name => $system_item) {
          
          // Register all globally included .less stylesheets.
          if (!empty($system_item->info['stylesheets'])) {
            foreach ($system_item->info['stylesheets'] as $stylesheets) {
              foreach ($stylesheets as $stylesheet) {
                if (drupal_substr($stylesheet, -5) == '.less') {
                  $static_stylesheets[$stylesheet] = $system_item_name;
                }
              }
            }
          }
          
          // Process LESS settings from .info files.
          if (isset($system_item->info['less']) && is_array($system_item->info['less'])) {
            
            // Register all non-global stylesheets.
            if (isset($system_item->info['less']['sheets']) && is_array($system_item->info['less']['sheets'])) {
              
              $system_item_path = drupal_get_path($system_item->type, $system_item->name);
              
              foreach ($system_item->info['less']['sheets'] as $stylesheet) {
                $static_stylesheets[$system_item_path . '/' . $stylesheet] = $system_item_name;
              }
            }
            
            // Register variable defaults.
            if (isset($system_item->info['less']['vars']) && is_array($system_item->info['less']['vars'])) {
              $static_defaults[$system_item_name] = $system_item->info['less']['vars'];
            }
          }
          
          // Invoke hook_less_variables(), results should be static.
          if ($module_defaults = module_invoke($system_item_name, 'less_variables')) {
            $static_defaults[$system_item_name] = array_merge((array) $static_defaults[$system_item_name], array_filter($module_defaults));
          }
        }
      }

      cache_set('less:stylesheets', $static_stylesheets);
      cache_set('less:defaults', $static_defaults);
    }
  }
  
}

/**
 * Returns the owning module/theme for a passed in .less file, or NULL.
 * Only can resolve .less files that are added using .info files.
 * 
 * @param $filepath
 * 
 * @return system name or NULL.
 */
function _less_file_owner($filepath) {
  // Use the advanced drupal_static() pattern, since this is called very often.
  static $drupal_static_fast;
  if (!isset($drupal_static_fast)) {
    $drupal_static_fast['cache'] = &drupal_static('less_stylesheets');
    
    if (!isset($drupal_static_fast['cache'])) {
      _less_registry();
    }
  }
  $stylesheets_cache = &$drupal_static_fast['cache'];
  
  return isset($stylesheets_cache[$filepath]) ? $stylesheets_cache[$filepath] : NULL;
}

/**
 * Returns the compiled list of variables and functions for a module/theme.
 * 
 * @param $system_name
 *   Module or theme system name.
 */
function less_get_settings($system_name = NULL) {
  // Use the advanced drupal_static() pattern, since this is called very often.
  static $drupal_static_fast;
  if (!isset($drupal_static_fast)) {
    $drupal_static_fast['cache'] = &drupal_static(__FUNCTION__);
  }
  $less_settings_static = &$drupal_static_fast['cache'];
  
  if (!isset($less_settings_static[$system_name])) {
    
    global $theme;
    
    $theme_settings = theme_get_setting('less', $theme);
    
    $defaults_cache = &drupal_static('less_defaults');
    
    if (!isset($defaults_cache)) {
      _less_registry();
    }
    
    $data = array(
      'variables' => array(),
      'functions' => array(
        'token' => '_less_token_replace',
      ),
    );
    
    
    /**
     * Compile the LESS variables.
     */
    // Cached default variables from .info files and hook_less_variables().
    if (!empty($defaults_cache[$system_name])) {
      $data['variables'] = array_merge($data['variables'], array_filter($defaults_cache[$system_name]));
    }
    
    // Saved variable values from current theme.
    if (!is_null($theme_settings) && !empty($theme_settings[$system_name])) {
      $data['variables'] = array_merge($data['variables'], array_filter($theme_settings[$system_name]));
    }
    
    // Prevent $system_name from being altered.
    $alter_system_name = $system_name;
    // Invoke hook_less_variables_alter().
    drupal_alter('less_variables', $data['variables'], $alter_system_name);
    // Invoke hook_less_variables_SYSTEM_NAME_alter().
    drupal_alter('less_variables_' . $system_name, $data['variables']);
    
    
    /**
     * Grab the LESS functions.
     * 
     * LESS functions are not stored in the cache table since they could be
     * anonymous functions.
     */
    if (module_hook($system_name, 'less_functions')) {
      $data['functions'] = array_merge($data['functions'], (array) module_invoke($system_name, 'less_functions'));
    }
    
    // Prevent $system_name from being altered.
    $alter_system_name = $system_name;
    // Invoke hook_less_functions_alter().
    drupal_alter('less_functions', $data['functions'], $alter_system_name);
    // Invoke hook_less_functions_system_name_alter().
    drupal_alter('less_functions_' . $system_name, $data['functions']);
    
    
    $less_settings_static[$system_name] = $data;
  }
  
  // Don't need to test isset(), there will always be data at $system_name.
  return $less_settings_static[$system_name];
}

/**
 * Handler for LESS function token().
 */
function _less_token_replace($arg) {
  list($type, $delimeter, $value) = $arg;
  
  return array($type, $delimeter, array(token_replace($value[0])));
}

/**
 * Handles AJAX requests to check for changes to files while in developer mode.
 */
function _less_watch() {
  
  drupal_page_is_cacheable(FALSE);
  
  $changed_files = array();
  
  if (variable_get('less_watch', FALSE)) {
    
    $files = (isset($_POST['less_files']) && is_array($_POST['less_files'])) ? $_POST['less_files'] : array();
    
    foreach ($files as $file) {
      if ($cache = cache_get('less:watch:' . drupal_hash_base64($file))) {
        $cached_data = $cache->data;
        
        $current_mtime = filemtime($cached_data['output_file']);
        
        $styles = array(
          '#items' => array(
            $cached_data['data'] => $cached_data,
          ),
        );
        
        $styles = _less_pre_render($styles);
        
        if (filemtime($styles['#items'][$cached_data['data']]['data']) > $current_mtime) {
          $changed_files[] = array(
            'old_file' => $file,
            'new_file' => file_create_url($styles['#items'][$cached_data['data']]['data']),
          );
        }
      }
    }
  }
  
  return $changed_files;
}

/**
 * Helper function that attempts to create a folder if it doesn't exist.
 * Locks are used to help avoid concurrency collisions.
 */
function _less_ensure_directory($directory_path) {
  
  $is_dir = is_dir($directory_path);
  
  if (!$is_dir) {
    
    $lock_id = 'less_directory_' . md5($directory_path);
    
    // Attempt to create directory only 3 times, else delay is too long.
    for ($i = 0; $i < 3; $i++) {
      
      if (lock_acquire($lock_id) && $is_dir = file_prepare_directory($directory_path, FILE_CREATE_DIRECTORY)) {
        // Creation was successful, cancel the 'for' loop;
        break;
      }
      
      lock_wait($lock_id, 1);
    }
    
    lock_release($lock_id);
    
    if (!$is_dir) {
      // There is a problem with the directory.
      $message = 'LESS could not create a directory in %dir';
      $message_vars = array('%dir' => $directory_path);
      
      watchdog('LESS', $message, $message_vars, WATCHDOG_ERROR);
      
      if (user_access(LESS_PERMISSION)) {
        drupal_set_message(t($message, $message_vars), 'error', FALSE);
      }
      
    }
  }
  
  return $is_dir;
}
