<?php

/**
 * @file
 * Import functions for menu_import module.
 */

/**
 * Helper function to find node associated to path.
 *
 * @param $path
 *   Path (node/* or any other) as described in the input file.
 * @param $title
 *   Node's title as described in the input file.
 * @param $options
 *   Array import options provided.
 *
 * @return
 *   An array('nid' => '<node_id_or_false>', 'link_path' => '<path_if_exists_or_empty>').
 */
function _menu_import_lookup_path($path, $title, array $options) {
  $result = array(
    'nid' => FALSE,
    'link_path' => ''
  );

  // Search by alias by default, but use only
  // path without query/argument part.
  $query = '';
  $path = explode('?', $path);
  if (count($path) != 1) {
    $query = '?' . $path[1];
  }
  $path = $path[0];

  // Does an alias exist in the system?
  $system_url = drupal_lookup_path('source', $path);
  if (!$system_url) {
    $system_url = $path;
  }

  // Try to detect a direct path to node.
  $matches = array();
  if (preg_match('/^node\/(\d+)/', $system_url, $matches)) {
    // The node exists and we can safely link to it.
    if (drupal_valid_path($system_url)) {
      if ($options['link_to_content']) {
        $result['link_path'] = $system_url . $query;
        $result['nid'] = $matches[1];
      }
    }
    // The node doesn't exist and we have to find it by title.
    elseif ($options['link_to_content']) {
      $nid = db_select('node', 'n')
        ->fields('n', array('nid'))
        ->condition('n.title', $title)
        ->execute()->fetchField();

      // The node exists.
      if ($nid) {
        $result['link_path'] = 'node/' . $nid . $query;
        $result['nid'] = $nid;
      }
    }
  }
  // Is there any registered path?
  elseif (drupal_valid_path($system_url)) {
    $result['link_path'] = $system_url . $query;
  }

  return $result;
}

/**
 * Parse indentation and title information from a menu item definition line.
 *
 * @param string $line the menu item line.
 * @return array of two items: 0 => level, 1 => title.
 */
function _menu_import_parse_level_title($line) {
  $matches = array();
  if (preg_match('/^([\-]+|[\*]+)?(\s+)?(.*)$/', $line, $matches)) {
    $level = strlen($matches[1]); // No sense to use drupal_strlen on indentation.
    $title = trim($matches[3]);

    return array($level, $title);
  }
  else {
    return FALSE;
  }
}

/**
 * Marks menu item as erroneous and returns it.
 */
function _menu_import_mark_error_item($item, $error, $original) {
  $item['error'] = $error;
  $item['link_title'] = '<span class="error">' . check_plain($original) . '</span>';
  return $item;
}

/**
 * Returns 1 if body is translatable (entity_translation enabled and configured)
 * and 0 otherwise.
 */
function _menu_import_body_is_translatable() {
  $body_trans = &drupal_static(__FUNCTION__, NULL);

  if (is_null($body_trans)) {
    if (db_field_exists('field_config', 'translatable')) {
      $body_trans =
        db_select('field_config', 'fc')
          ->fields('fc', array('translatable'))
          ->condition('field_name', 'body')
          ->execute()
          ->fetchColumn();
    }
    else {
      $body_trans = 0;
    }
  }

  return $body_trans;
}

/**
 * Parse a line of text containing the menu structure.
 * Only * and - are allowed as indentation characters.
 * Menu item definition may or may not contain details in JSON format.
 *
 * @param $line
 *   One line from input file.
 * @param $prev_level
 *   Previous level to build ierarchy.
 * @param $weights
 *   Array of menu items' weights.
 * @param $parents
 *   Array of menu items' parents.
 * @param $options
 *   Array of importing options.
 *
 * @return
 *   Array representing a menu item.
 */
function menu_import_parse_line($line, $prev_level, array $weights, array $parents, array $options) {
  $menuitem = array(
    'error' => FALSE,
    'link_title' => NULL,
    'children' => array(),
    'parent' => 0,
    'nid' => FALSE,
    'path' => FALSE,
    'weight' => 0,
    'external' => FALSE,
    'level' => 0,
  );

  // Set default language
  if (module_exists('i18n_menu')) {
    $menuitem['language'] = $options['language'];
  }
  $langs = array_keys(language_list());

  $path = $description = $expanded = $hidden = $language = '';

  // JSON is used.
  // @todo: make the input JSON not so strict.
  if (($json_start = strpos($line, '{"')) != 0) {
    $json = substr($line, $json_start);
    $details = json_decode($json);

    // Parse structure and title.
    $base_info = substr($line, 0, $json_start);
    $level_title = _menu_import_parse_level_title($base_info);

    // Extract details.
    if (!is_null($details)) {
      $path = !empty($details->url) ? trim($details->url) : '';
      $description = !empty($details->description) ? trim($details->description) : '';
      $expanded = !empty($details->expanded);
      $hidden = !empty($details->hidden);
      $language = !empty($details->lang) && in_array($details->lang, $langs)
                    ? $details->lang : NULL;
    }
    else {
      return _menu_import_mark_error_item($menuitem, t('malformed item details'), $line);
    }
  }
  // No JSON is provided, only level and title are specified.
  else {
    $level_title = _menu_import_parse_level_title($line);
  }

  if (!$level_title) {
    return _menu_import_mark_error_item($menuitem, t('missing title or wrong indentation'), $line);
  }
  else {
    list($level, $title) = $level_title;
  }

  // Skip empty items
  if (!strlen($title)) {
    return _menu_import_mark_error_item($menuitem, t('missing item title'), $line);
  }

  // Make sure this item is only 1 level below the last item.
  if ($level > $prev_level + 1) {
    return _menu_import_mark_error_item($menuitem, t('wrong indentation'), $line);
  }

  if (isset($weights[$level])) {
    if ($level > $prev_level) {
      $weight = 0;
    }
    else {
      $weight = $weights[$level] + 1;
    }
  }
  else {
    $weight = 0;
  }
  $menuitem['weight'] = $weight;
  $menuitem['parent'] = !$level ? 0 : $parents[$level - 1];
  $menuitem['link_title'] = $title;
  $menuitem['level'] = $level;
  $menuitem['path'] = $path;

  if (url_is_external($path)) {
    $menuitem['external'] = TRUE;
    $menuitem['link_path'] = $path;
  }
  else {
    $result = _menu_import_lookup_path($path, $title, $options);
    $menuitem['link_path'] = $result['link_path'];
    $menuitem['nid'] = $result['nid'];
  }

  if ($description) {
    $menuitem['description'] = $description;
  }
  if ($hidden) {
    $menuitem['hidden'] = 1;
  }
  if ($expanded) {
    $menuitem['expanded'] = 1;
  }
  if ($language) {
    $menuitem['language'] = $language;
    // Important when setting the language, otherwise it'll be ignored.
    $menuitem['customized'] = 1;
  }

  return $menuitem;
}

/**
 * File parser function. Reads through the text file and constructs the menu.
 *
 * @param $uri
 *   uri of the uploaded file.
 * @param $menu_name
 *   internal name of existiong menu.
 * @param $options
 *   An associative array of search options.
 *   - search_title: search node by title.
 *
 * @return array
 *   array structure of menu.
 */
function menu_import_parse_menu_from_file($uri, $menu_name, array $options) {
  $menu = array(
    'errors'    => array(),
    'warnings'  => array(),
    0 => array(
      'menu_name' => $menu_name,
      'children' => array(),
    )
  );

  // Keep track of actual weights per level.
  // We have to append to existing items not to mess up the menu.
  $weights = array(0 => menu_import_get_max_weight($menu_name));
  // Keep track of actual parents per level.
  $parents = array();

  $handle = fopen($uri, "r");
  if (!$handle) {
    $menu['errors'][] = t("Couldn't open the uploaded file for reading.");
    return $menu;
  }

  $level = $current_line = $empty_lines = 0;
  while ($line = fgets($handle)) {
    $current_line++;

    // Skip empty lines.
    if (preg_match('/^\s*$/', $line)) {
      $empty_lines++;
    }
    else {
      $menuitem = menu_import_parse_line($line, $level, $weights, $parents, $options);
      if ($menuitem['error']) {
        $menu['errors'][] = t('Error on line @line_number: @error.', array('@line_number' => $current_line, '@error' => $menuitem['error']));
      }
      $menu[$current_line] = $menuitem;
      $menu[$menuitem['parent']]['children'][] = $current_line;

      $level = $menuitem['level'];
      $parents[$level] = $current_line;
      $weights[$level] = $menuitem['weight'];
    }
  }

  if ($empty_lines) {
    $menu['warnings'][] = t('Empty lines skipped: @line_number.', array('@line_number' => $empty_lines));
  }

  fclose($handle);

  return $menu;
}

/**
 * Text parser function. Reads through the text and constructs the menu.
 *
 * @param $text
 *   Text containing the menu structure.
 *
 * @see menu_import_parse_menu_from_file()
 */
function menu_import_parse_menu_from_string($text, $menu_name, array $options) {
  $menu = array(
    'errors' => array(),
    'warnings'  => array(),
    0 => array(
      'menu_name' => $menu_name,
      'children' => array(),
    )
  );

  // Keep track of actual weights per level.
  $weights = array();
  // Keep track of actual parents per level.
  $parents = array();

  $level = $current_line = $empty_lines = 0;
  $lines = explode("\n", $text);
  foreach ($lines as $line) {
    $current_line++;

    // Skip empty lines.
    if (preg_match('/^\s*$/', $line)) {
      $empty_lines++;
    }
    else {
      $menuitem = menu_import_parse_line($line, $level, $weights, $parents, $options);
      if ($menuitem['error']) {
        $menu['errors'][] = t('Error on line @line_number: @error.', array('@line_number' => $current_line, '@error' => $menuitem['error']));
      }
      $menu[$current_line] = $menuitem;
      $menu[$menuitem['parent']]['children'][] = $current_line;

      $level = $menuitem['level'];
      $parents[$level] = $current_line;
      $weights[$level] = $menuitem['weight'];
    }
  }

  if ($empty_lines) {
    $menu['warnings'][] = t('Empty lines skipped: @line_number.', array('@line_number' => $empty_lines));
  }

  return $menu;
}

/**
 * Import menu items.
 *
 * @param $menu
 *   An associative array containing the menu structure.
 * @param $options
 *   An associative array of import options.
 *   - link_to_content: look for existing nodes and link to them
 *   - create_content: create new content (also if link_to_content not set)
 *   - remove_menu_items: removes current menu items
 *   - node_type: node type
 *   - node_body: node body
 *   - node_author: node author
 *   - node_status: node status
 *
 * @return
 *   Array of different statistics accumulated during the import.
 */
function menu_import_save_menu($menu, $options) {
  $nodes_deleted_cnt = $unknown_links_cnt = $external_links_cnt = 0;
  $nodes_matched_cnt = $nodes_new_cnt = $failed_cnt = 0;

  // Delete existing menu items.
  $menu_name = $menu[0]['menu_name'];
  if ($options['remove_menu_items']) {
    $nodes_deleted_cnt = menu_import_delete_menuitems($menu_name);
  }

  $menu[0]['mlid'] = 0;

  foreach ($menu as $item) {
    if (!isset($item['children'])) {
      continue;
    }

    foreach ($item['children'] as $index) {
      $menuitem = $menu[$index];
      $menuitem['plid'] = $menu[$menuitem['parent']]['mlid'];
      $menuitem['menu_name'] = $menu_name;

      // Determine if this is the path to view a node (no edit or whatever).
      $link_path = $menuitem['link_path'];
      if ($link_path && arg(0, $link_path) == 'node'
            && is_numeric(arg(1, $link_path)) && !arg(2, $link_path)) {
        $node_path = true;
      }
      else {
        $node_path = false;
      }

      // Do not create nodes for external links.
      if ($menuitem['external']) {
        $external_links_cnt++;
      }
      // Internal link to not-a-content (settings, custom module path etc).
      elseif ($link_path && !$node_path) {
        $unknown_links_cnt++;
      }
      // Handle links to nodes or missing links.
      else {
        // Node exists.
        if ($menuitem['nid']) {
          // Try to link to existing content first.
          if ($options['link_to_content']) {
            menu_import_delete_node_menuitem($menuitem);
            $nodes_matched_cnt++;
          }
          // We need to create new content only if no linking was selected.
          elseif ($options['create_content']) {
            menu_import_delete_node_menuitem($menuitem);
            $options['node_title'] = $menuitem['link_title'];
            $nid = menu_import_create_node($options);
            $menuitem['nid'] = $nid;
            $nodes_new_cnt++;
            $menuitem['link_path'] = 'node/' . $menuitem['nid'];
          }
        }
        // Node doesn't exist.
        else {
          // Create new link and node.
          if ($options['create_content']) {
            $options['node_title'] = $menuitem['link_title'];
            if ($options['node_alias'] && !empty($menuitem['path'])
                && arg($menuitem['path'], 0) != 'node') {
              $options['path_alias'] = $menuitem['path'];
            }
            $nid = menu_import_create_node($options);
            $menuitem['nid'] = $nid;
            $nodes_new_cnt++;
            $menuitem['link_path'] = 'node/' . $menuitem['nid'];
          }
          // Recreate menu item.
          else {
            $unknown_links_cnt++;
            $menuitem['link_path'] = '<front>';
          }
        }
      }

      // Ensure we link to at least front page.
      if (empty($menuitem['link_path'])) {
        $menuitem['link_path'] = '<front>';
      }

      // Save description if available.
      if (isset($menuitem['description'])) {
        $menuitem['options']['attributes']['title'] = $menuitem['description'];
      }

      // Save menuitem and set mlid.
      $mlid = menu_link_save($menuitem);
      if (!$mlid) {
        $failed_cnt++;
      }
      $menu[$index]['mlid'] = $mlid;
    }
  }

  return array(
    'external_links' => $external_links_cnt,
    'unknown_links' => $unknown_links_cnt,
    'matched_nodes' => $nodes_matched_cnt,
    'new_nodes' => $nodes_new_cnt,
    'deleted_menu_items' => $nodes_deleted_cnt,
    'failed' => $failed_cnt,
  );
}

/**
 * Create new node of given content type.
 *
 * @param $options
 *   Array relevant array keys are:
 *   - node_title
 *   - node_type
 *   - node_body
 *   - node_author
 *   - node_status
 *
 * @return
 *   Node's nid field.
 */
function menu_import_create_node($options) {
  $node = new stdClass();

  $node->type     = $options['node_type'];
  $node->language = $options['language'];
  $node->title    = $options['node_title'];

  $body_lang = _menu_import_body_is_translatable() ? $node->language : LANGUAGE_NONE;
  $node->body[$body_lang][0]['value']   = $options['node_body'];
  $node->body[$body_lang][0]['summary'] = text_summary($options['node_body']);
  $node->body[$body_lang][0]['format']  = $options['node_format'];

  $node->status = $options['node_status'];
  $node->uid    = $options['node_author'];

  if (!empty($options['path_alias'])) {
    $node->path = array('alias' => $options['path_alias']);
    // Make sure pathauto is not being used
    if (module_exists('pathauto')) {
      $node->path['pathauto'] = FALSE;
    }
  }

  node_save($node);
  return $node->nid;
}

/**
 * Delete nodes attached to menu and menu items.
 *
 * @param $menu_name
 *   The machine name of the menu.
 * @param $with_nodes
 *   Delete nodes as well. Not used, reserved for future.
 *
 * @return
 *   Number of items deleted .
 */
function menu_import_delete_menuitems($menu_name, $with_nodes = FALSE) {
  $menuitems = db_select('menu_links', 'ml')
    ->fields('ml', array('mlid', 'link_path'))
    ->condition('ml.menu_name', $menu_name)
    ->execute()->fetchAllAssoc('mlid', PDO::FETCH_ASSOC);

  $deleted_cnt = 0;
  foreach ($menuitems as $menuitem) {
    db_delete('menu_links')->condition('mlid', $menuitem['mlid'])->execute();
    if ($with_nodes) {
      // Delete nodes only
      $link = explode('/', $menuitem['link_path']);
      if ($link[0] == 'node' && is_numeric($link[1])) {
        $nid = $link[1];
        db_delete('node')->condition('nid', $nid)->execute();
        db_delete('node_revision')->condition('nid', $nid)->execute();
      }
    }
    $deleted_cnt++;
  }

  return $deleted_cnt;
}

/**
 * Delete menu item by nid.
 *
 * @param $menuitem
 *   Array describing the menu item.
 */
function menu_import_delete_node_menuitem($menuitem) {
  $processed_items = &drupal_static(__FUNCTION__, array());
  $path = 'node/' . $menuitem['nid'];
  if (!in_array($path, $processed_items)) {
    menu_link_delete(NULL, $path);
    $processed_items[] = $path;
  }
}

/**
 * Returns the array of messages shown when import is done
 * with the same keys as menu_import_save_menu returns.
 * @return array
 */
function menu_import_get_messages() {
  return array(
    'items_imported'=> 'Items imported: @count.',
    'failed'        => 'Items failed: @count.',
    'new_nodes'     => 'New content created: @count items.',
    'matched_nodes' => 'Existing content matched: @count items.',
    'deleted_menu_items'  => 'Menu items deleted: @count.',
    'external_links'      => 'External URLs: @count items.',
    'unknown_links'       => 'Content not found: @count items.'
  );
}

/**
 * Get max weight for first level.
 */
function menu_import_get_max_weight($menu_name) {
  $weight = db_query("SELECT MAX(weight) FROM {menu_links} WHERE menu_name = :menu_name AND depth = 1",
    array(':menu_name' => $menu_name))->fetchField();
  if (empty($weight)) {
    $weight = 0;
  }
  return $weight;
}
