/*
 * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional information regarding
 * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance with the License. You may obtain a
 * copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package org.apache.geode.management.internal.cli.help;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;

import org.apache.commons.lang.StringUtils;
import org.springframework.shell.core.MethodTarget;
import org.springframework.shell.core.annotation.CliAvailabilityIndicator;
import org.springframework.shell.core.annotation.CliCommand;
import org.springframework.shell.core.annotation.CliOption;

import org.apache.geode.management.cli.CliMetaData;
import org.apache.geode.management.internal.cli.GfshParser;
import org.apache.geode.management.internal.cli.i18n.CliStrings;

/**
 *
 *
 * @since GemFire 7.0
 */
public class Helper {

  static final String NAME_NAME = "NAME";
  static final String SYNONYMS_NAME = "SYNONYMS";
  static final String SYNOPSIS_NAME = "SYNOPSIS";
  static final String SYNTAX_NAME = "SYNTAX";
  static final String OPTIONS_NAME = "PARAMETERS";
  static final String IS_AVAILABLE_NAME = "IS AVAILABLE";

  static final String REQUIRED_SUB_NAME = "Required: ";
  static final String SYNONYMS_SUB_NAME = "Synonyms: ";
  static final String SPECIFIEDDEFAULTVALUE_SUB_NAME =
      "Default (if the parameter is specified without value): ";
  static final String UNSPECIFIEDDEFAULTVALUE_VALUE_SUB_NAME =
      "Default (if the parameter is not specified): ";

  static final String VALUE_FIELD = "value";
  static final String TRUE_TOKEN = "true";
  static final String FALSE_TOKEN = "false";
  static final String AVAILABLE = "Available";
  static final String NOT_AVAILABLE = "Not Available";

  private final Map<String, Topic> topics = new HashMap<>();
  private final Map<String, Method> commands = new TreeMap<String, Method>();
  private final Map<String, MethodTarget> availabilityIndicators =
      new HashMap<String, MethodTarget>();

  public Helper() {
    initTopic(CliStrings.DEFAULT_TOPIC_GEODE, CliStrings.DEFAULT_TOPIC_GEODE__DESC);
    initTopic(CliStrings.TOPIC_GEODE_REGION, CliStrings.TOPIC_GEODE_REGION__DESC);
    initTopic(CliStrings.TOPIC_GEODE_WAN, CliStrings.TOPIC_GEODE_WAN__DESC);
    initTopic(CliStrings.TOPIC_GEODE_JMX, CliStrings.TOPIC_GEODE_JMX__DESC);
    initTopic(CliStrings.TOPIC_GEODE_DISKSTORE, CliStrings.TOPIC_GEODE_DISKSTORE__DESC);
    initTopic(CliStrings.TOPIC_GEODE_LOCATOR, CliStrings.TOPIC_GEODE_LOCATOR__DESC);
    initTopic(CliStrings.TOPIC_GEODE_SERVER, CliStrings.TOPIC_GEODE_SERVER__DESC);
    initTopic(CliStrings.TOPIC_GEODE_MANAGER, CliStrings.TOPIC_GEODE_MANAGER__DESC);
    initTopic(CliStrings.TOPIC_GEODE_STATISTICS, CliStrings.TOPIC_GEODE_STATISTICS__DESC);
    initTopic(CliStrings.TOPIC_GEODE_LIFECYCLE, CliStrings.TOPIC_GEODE_LIFECYCLE__DESC);
    initTopic(CliStrings.TOPIC_GEODE_M_AND_M, CliStrings.TOPIC_GEODE_M_AND_M__DESC);
    initTopic(CliStrings.TOPIC_GEODE_DATA, CliStrings.TOPIC_GEODE_DATA__DESC);
    initTopic(CliStrings.TOPIC_GEODE_CONFIG, CliStrings.TOPIC_GEODE_CONFIG__DESC);
    initTopic(CliStrings.TOPIC_GEODE_FUNCTION, CliStrings.TOPIC_GEODE_FUNCTION__DESC);
    initTopic(CliStrings.TOPIC_GEODE_HELP, CliStrings.TOPIC_GEODE_HELP__DESC);
    initTopic(CliStrings.TOPIC_GEODE_DEBUG_UTIL, CliStrings.TOPIC_GEODE_DEBUG_UTIL__DESC);
    initTopic(CliStrings.TOPIC_GFSH, CliStrings.TOPIC_GFSH__DESC);
    initTopic(CliStrings.TOPIC_LOGS, CliStrings.TOPIC_LOGS__DESC);
    initTopic(CliStrings.TOPIC_CLIENT, CliStrings.TOPIC_CLIENT__DESC);
  }

  private void initTopic(String topic, String desc) {
    topics.put(topic, new Topic(topic, desc));
  }

  public void addCommand(CliCommand command, Method commandMethod) {
    // put all the command synonyms in the command map
    Arrays.stream(command.value()).forEach(cmd -> {
      commands.put(cmd, commandMethod);
    });

    // resolve the hint message for each method
    CliMetaData cliMetaData = commandMethod.getDeclaredAnnotation(CliMetaData.class);
    if (cliMetaData == null)
      return;
    String[] related = cliMetaData.relatedTopic();

    // for hint message, we only need to show the first synonym
    String commandString = command.value()[0];
    if (related == null) {
      return;
    }
    Arrays.stream(related).forEach(topic -> {
      Topic foundTopic = topics.get(topic);
      if (foundTopic == null) {
        throw new IllegalArgumentException("No such topic found in the initial map: " + topic);
      }
      foundTopic.addRelatedCommand(commandString, command.help());
    });
  }

  public void addAvailabilityIndicator(CliAvailabilityIndicator availability, MethodTarget target) {
    Arrays.stream(availability.value()).forEach(command -> {
      availabilityIndicators.put(command, target);
    });
  }

  /**
   * get help string for a specific command, or a brief description of all the commands if buffer is
   * null or empty
   */
  public String getHelp(String buffer, int terminalWidth) {
    if (StringUtils.isBlank(buffer)) {
      return getHelp().toString(terminalWidth);
    }

    Method method = commands.get(buffer);
    if (method == null) {
      return "no help exists for this command.";
    }

    HelpBlock helpBlock = getHelp(method.getDeclaredAnnotation(CliCommand.class),
        method.getParameterAnnotations(), method.getParameterTypes());
    return helpBlock.toString(terminalWidth);
  }

  public String getHint(String buffer) {
    StringBuilder builder = new StringBuilder();
    // if no topic is provided, return a list of topics
    if (StringUtils.isBlank(buffer)) {
      builder.append(CliStrings.HINT__MSG__TOPICS_AVAILABLE).append(GfshParser.LINE_SEPARATOR)
          .append(GfshParser.LINE_SEPARATOR);

      List<String> sortedTopics = new ArrayList<>(topics.keySet());
      Collections.sort(sortedTopics);
      sortedTopics.stream()
          .forEachOrdered(topic -> builder.append(topic).append(GfshParser.LINE_SEPARATOR));
      return builder.toString();
    }

    Topic topic = topics.get(buffer);
    if (topic == null) {
      return CliStrings.format(CliStrings.HINT__MSG__UNKNOWN_TOPIC, buffer);
    }

    builder.append(topic.desc).append(GfshParser.LINE_SEPARATOR).append(GfshParser.LINE_SEPARATOR);
    Collections.sort(topic.relatedCommands);
    topic.relatedCommands.stream().forEachOrdered(command -> builder.append(command.command)
        .append(": ").append(command.desc).append(GfshParser.LINE_SEPARATOR));
    return builder.toString();
  }

  public Set<String> getTopicNames() {
    return topics.keySet();
  }

  boolean isAvailable(String command) {
    MethodTarget target = availabilityIndicators.get(command);
    if (target == null) {
      return true;
    }
    try {
      return (Boolean) target.getMethod().invoke(target.getTarget());
    } catch (Exception e) {
      return false;
    }
  }

  public boolean hasAvailabilityIndicator(String command) {
    return availabilityIndicators.get(command) != null;
  }

  HelpBlock getHelp() {
    HelpBlock root = new HelpBlock();
    commands.keySet().stream().sorted().map(commands::get).forEach(method -> {
      root.addChild(getHelp(method.getDeclaredAnnotation(CliCommand.class), null, null));
    });
    return root;
  }

  /**
   * returns a short description and help string of the command if annotations is null or returns a
   * details description of the command with the syntax and parameter description
   */
  HelpBlock getHelp(CliCommand cliCommand, Annotation[][] annotations, Class<?>[] parameterTypes) {
    String commandName = cliCommand.value()[0];
    boolean isAvailable = isAvailable(commandName);

    if (annotations == null && parameterTypes == null) {
      String available = isAvailable ? AVAILABLE : NOT_AVAILABLE;
      HelpBlock help = new HelpBlock(commandName + " (" + available + ")");
      help.addChild(new HelpBlock(cliCommand.help()));
      return help;
    }

    HelpBlock root = new HelpBlock();
    // First we will have the block for NAME of the command
    HelpBlock name = new HelpBlock(NAME_NAME);
    name.addChild(new HelpBlock(commandName));
    root.addChild(name);

    // add the availability flag
    HelpBlock availability = new HelpBlock(IS_AVAILABLE_NAME);
    availability.addChild(new HelpBlock(isAvailable + ""));
    root.addChild(availability);

    // Now add synonyms if any
    String[] allNames = cliCommand.value();
    if (allNames.length > 1) {
      HelpBlock synonyms = new HelpBlock(SYNONYMS_NAME);
      for (int i = 1; i < allNames.length; i++) {
        synonyms.addChild(new HelpBlock(allNames[i]));
      }
      root.addChild(synonyms);
    }

    // Now comes the turn to display synopsis if any
    if (StringUtils.isNotBlank(cliCommand.help())) {
      HelpBlock synopsis = new HelpBlock(SYNOPSIS_NAME);
      synopsis.addChild(new HelpBlock(cliCommand.help()));
      root.addChild(synopsis);
    }

    // Now display the syntax for the command
    HelpBlock syntaxBlock = new HelpBlock(SYNTAX_NAME);
    String syntax = getSyntaxString(commandName, annotations, parameterTypes);
    syntaxBlock.addChild(new HelpBlock(syntax));
    root.addChild(syntaxBlock);

    // Detailed description of all the Options
    if (annotations.length > 0) {
      HelpBlock options = new HelpBlock(OPTIONS_NAME);
      for (int i = 0; i < annotations.length; i++) {
        CliOption cliOption = getAnnotation(annotations[i], CliOption.class);
        HelpBlock optionNode = getOptionDetail(cliOption);
        options.addChild(optionNode);
      }
      root.addChild(options);
    }
    return root;
  }

  HelpBlock getOptionDetail(CliOption cliOption) {
    HelpBlock optionNode = new HelpBlock(getPrimaryKey(cliOption));
    String help = cliOption.help();
    optionNode.addChild(new HelpBlock((StringUtils.isNotBlank(help) ? help : "")));
    if (getSynonyms(cliOption).size() > 0) {
      StringBuilder builder = new StringBuilder();
      for (String string : getSynonyms(cliOption)) {
        if (builder.length() > 0) {
          builder.append(",");
        }
        builder.append(string);
      }
      optionNode.addChild(new HelpBlock(SYNONYMS_SUB_NAME + builder.toString()));
    }
    optionNode.addChild(
        new HelpBlock(REQUIRED_SUB_NAME + ((cliOption.mandatory()) ? TRUE_TOKEN : FALSE_TOKEN)));
    if (!isNullOrBlank(cliOption.specifiedDefaultValue())) {
      optionNode.addChild(
          new HelpBlock(SPECIFIEDDEFAULTVALUE_SUB_NAME + cliOption.specifiedDefaultValue()));
    }
    if (!isNullOrBlank(cliOption.unspecifiedDefaultValue())) {
      optionNode.addChild(new HelpBlock(
          UNSPECIFIEDDEFAULTVALUE_VALUE_SUB_NAME + cliOption.unspecifiedDefaultValue()));
    }
    return optionNode;
  }

  private <T> T getAnnotation(Annotation[] annotations, Class<?> klass) {
    for (Annotation annotation : annotations) {
      if (klass.isAssignableFrom(annotation.getClass())) {
        return (T) annotation;
      }
    }
    return null;
  }

  String getSyntaxString(String commandName, Annotation[][] annotations, Class[] parameterTypes) {
    StringBuffer buffer = new StringBuffer();
    buffer.append(commandName);
    for (int i = 0; i < annotations.length; i++) {
      CliOption cliOption = getAnnotation(annotations[i], CliOption.class);
      String optionString = getOptionString(cliOption, parameterTypes[i]);
      if (cliOption.mandatory()) {
        buffer.append(" ").append(optionString);
      } else {
        buffer.append(" [").append(optionString).append("]");
      }
    }
    return buffer.toString();
  }

  /**
   * this builds the following format of strings: key (as in sh and help) --key=value --key(=value)?
   * (if has specifiedDefaultValue) --key=value(,value)* (if the value is a list)
   *
   * @return option string
   */
  private static String getOptionString(CliOption cliOption, Class<?> optionType) {
    String key0 = cliOption.key()[0];
    if ("".equals(key0)) {
      return (cliOption.key()[1]);
    }

    StringBuffer buffer = new StringBuffer();
    buffer.append(GfshParser.LONG_OPTION_SPECIFIER).append(key0);

    boolean hasSpecifiedDefault = !isNullOrBlank(cliOption.specifiedDefaultValue());

    if (hasSpecifiedDefault) {
      buffer.append("(");
    }

    buffer.append(GfshParser.OPTION_VALUE_SPECIFIER).append(VALUE_FIELD);

    if (hasSpecifiedDefault) {
      buffer.append(")?");
    }

    if (isCollectionOrArrayType(optionType)) {
      buffer.append("(").append(",").append(VALUE_FIELD).append(")*");
    }

    return buffer.toString();
  }

  private static boolean isCollectionOrArrayType(Class<?> typeToCheck) {
    return typeToCheck != null
        && (typeToCheck.isArray() || Collection.class.isAssignableFrom(typeToCheck));
  }

  private static String getPrimaryKey(CliOption option) {
    String[] keys = option.key();
    if (keys.length == 0) {
      throw new RuntimeException("Invalid option keys");
    } else if ("".equals(keys[0])) {
      return keys[1];
    } else {
      return keys[0];
    }
  }

  private static List<String> getSynonyms(CliOption option) {
    List<String> synonyms = new ArrayList<>();
    String[] keys = option.key();
    if (keys.length < 2)
      return synonyms;
    // if the primary key is empty (like sh and help command), then there should be no synonyms.
    if ("".equals(keys[0]))
      return synonyms;

    for (int i = 1; i < keys.length; i++) {
      synonyms.add(keys[i]);
    }
    return synonyms;
  }

  private static boolean isNullOrBlank(String value) {
    return StringUtils.isBlank(value) || CliMetaData.ANNOTATION_NULL_VALUE.equals(value);
  }

  public Set<String> getCommands() {
    return commands.keySet();
  }
}
