/*
 * 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;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.springframework.shell.converters.ArrayConverter;
import org.springframework.shell.core.CommandMarker;
import org.springframework.shell.core.Completion;
import org.springframework.shell.core.Converter;
import org.springframework.shell.core.Parser;
import org.springframework.shell.core.SimpleParser;
import org.springframework.shell.event.ParseResult;

import org.apache.geode.management.cli.ConverterHint;
import org.apache.geode.management.internal.i18n.CliStrings;

/**
 * Implementation of the {@link Parser} interface for GemFire SHell (gfsh) requirements.
 *
 * @since GemFire 7.0
 */
public class GfshParser extends SimpleParser {

  public static final String LINE_SEPARATOR = System.lineSeparator();
  public static final String OPTION_VALUE_SPECIFIER = "=";
  public static final String OPTION_SEPARATOR = " ";
  public static final String SHORT_OPTION_SPECIFIER = "-";
  public static final String LONG_OPTION_SPECIFIER = "--";
  public static final String COMMAND_DELIMITER = ";";
  public static final String CONTINUATION_CHARACTER = "\\";

  private static final char ASCII_UNIT_SEPARATOR = '\u001F';
  public static final String J_ARGUMENT_DELIMITER = "" + ASCII_UNIT_SEPARATOR;
  public static final String J_OPTION_CONTEXT = "splittingRegex=" + J_ARGUMENT_DELIMITER;

  private final CommandManager commandManager;

  public GfshParser(CommandManager commandManager) {
    this.commandManager = commandManager;

    for (CommandMarker command : commandManager.getCommandMarkers()) {
      add(command);
    }

    for (Converter<?> converter : commandManager.getConverters()) {
      if (converter.getClass().isAssignableFrom(ArrayConverter.class)) {
        ArrayConverter arrayConverter = (ArrayConverter) converter;
        arrayConverter.setConverters(new HashSet<>(commandManager.getConverters()));
      }
      add(converter);
    }
  }

  static String convertToSimpleParserInput(String userInput) {
    List<String> inputTokens = splitUserInput(userInput);
    return getSimpleParserInputFromTokens(inputTokens);
  }

  /**
   * it's assumed that the quoted string should not have escaped quotes inside it.
   */
  private static List<String> splitWithWhiteSpace(String input) {
    List<String> tokensList = new ArrayList<>();
    StringBuilder token = new StringBuilder();
    char insideQuoteOf = Character.MIN_VALUE;

    for (char c : input.toCharArray()) {
      if (Character.isWhitespace(c)) {
        // if we are in the quotes
        if (insideQuoteOf != Character.MIN_VALUE) {
          token.append(c);
        }
        // if we are not in the quotes, terminate this token and add it to the list
        else {
          if (token.length() > 0) {
            tokensList.add(token.toString());
          }
          token = new StringBuilder();
        }
      }
      // not a white space
      else {
        token.append(c);
        // if encountering a quote
        if (c == '\'' || c == '\"') {
          // if this is the beginning of quote
          if (insideQuoteOf == Character.MIN_VALUE) {
            insideQuoteOf = c;
          }
          // this is the ending of quote
          else if (insideQuoteOf == c) {
            insideQuoteOf = Character.MIN_VALUE;
          }
        }
      }
    }
    if (token.length() > 0) {
      tokensList.add(token.toString());
    }
    return tokensList;
  }

  static List<String> splitUserInput(String userInput) {
    // first split with whitespaces except in quotes
    List<String> splitWithWhiteSpaces = splitWithWhiteSpace(userInput);

    List<String> furtherSplitWithEquals = new ArrayList<>();
    for (String token : splitWithWhiteSpaces) {
      // do not split with "=" if this part starts with quotes or is part of -D
      if (token.startsWith("'") || token.startsWith("\"") || token.startsWith("-D")) {
        furtherSplitWithEquals.add(token);
        continue;
      }
      // if this token has equal sign, split around the first occurrence of it
      int indexOfFirstEqual = token.indexOf('=');
      if (indexOfFirstEqual < 0) {
        furtherSplitWithEquals.add(token);
        continue;
      }
      String left = token.substring(0, indexOfFirstEqual);
      String right = token.substring(indexOfFirstEqual + 1);
      if (left.length() > 0) {
        furtherSplitWithEquals.add(left);
      }
      if (right.length() > 0) {
        furtherSplitWithEquals.add(right);
      }
    }
    return furtherSplitWithEquals;
  }

  static String getSimpleParserInputFromTokens(List<String> tokens) {
    // make a copy of the input since we need to do add/remove
    List<String> inputTokens = new ArrayList<>();

    // get the --J arguments from the list of tokens
    int firstJIndex = -1;
    List<String> jArguments = new ArrayList<>();

    for (int i = 0; i < tokens.size(); i++) {
      String token = tokens.get(i);
      if ("--J".equals(token)) {
        if (firstJIndex < 1) {
          firstJIndex = i;
        }
        i++;

        if (i < tokens.size()) {
          String jArg = tokens.get(i);
          // remove the quotes around each --J arugments
          if (jArg.charAt(0) == '"' || jArg.charAt(0) == '\'') {
            jArg = jArg.substring(1, jArg.length() - 1);
          }
          if (jArg.length() > 0) {
            jArguments.add(jArg);
          }
        }
      } else {
        inputTokens.add(token);
      }
    }

    // concatenate the remaining tokens with space
    StringBuilder rawInput = new StringBuilder();
    // firstJIndex must be less than or equal to the length of the inputToken
    for (int i = 0; i <= inputTokens.size(); i++) {
      // stick the --J arguments in the orginal first --J position
      if (i == firstJIndex) {
        rawInput.append("--J ");
        if (jArguments.size() > 0) {
          // quote the entire J argument with double quotes, and delimited with a special delimiter,
          // and we
          // need to tell the gfsh parser to use this delimiter when splitting the --J argument in
          // each command
          rawInput.append("\"").append(StringUtils.join(jArguments, J_ARGUMENT_DELIMITER))
              .append("\" ");
        }
      }
      // then add the next inputToken
      if (i < inputTokens.size()) {
        rawInput.append(inputTokens.get(i)).append(" ");
      }
    }

    return rawInput.toString().trim();
  }

  @Override
  public GfshParseResult parse(String userInput) {
    String rawInput = convertToSimpleParserInput(userInput);
    // this tells the simpleParser not to interpret backslash as escaping character
    rawInput = rawInput.replace("\\", "\\\\");
    // User SimpleParser to parse the input
    ParseResult result = super.parse(rawInput);

    if (result == null) {
      // do a quick check for required arguments, since SimpleParser unhelpfully suggests everything
      String missingHelp = commandManager.getHelper().getMiniHelp(userInput);
      if (missingHelp != null) {
        System.out.println(missingHelp);
      }

      return null;
    }

    return new GfshParseResult(result.getMethod(), result.getInstance(), result.getArguments(),
        userInput);
  }

  /**
   *
   * The super class's completeAdvanced has the following limitations: 1) for option name
   * completion, you need to end your buffer with --. 2) For command name completion, you need to
   * end your buffer with a space. 3) the above 2 completions, the returned value is always 0, and
   * the completion is the entire command 4) for value completion, you also need to end your buffer
   * with space, the returned value is the length of the original string, and the completion strings
   * are the possible values.
   *
   * With these limitations, we will need to overwrite this command with some customization
   *
   * @param cursor this input is ignored, we always move the cursor to the end of the userInput
   * @return the cursor point at which the candidate string will begin, this is important if you
   *         have only one candidate, cause tabbing will use it to complete the string for you.
   */

  @Override
  public int completeAdvanced(String userInput, int cursor, final List<Completion> candidates) {
    // move the cursor to the end of the input
    List<String> inputTokens = splitUserInput(userInput);

    // check if the input is before any option is specified, e.g. (start, describe)
    boolean inputIsBeforeOption = true;
    for (String token : inputTokens) {
      if (token.startsWith("--")) {
        inputIsBeforeOption = false;
        break;
      }
    }

    // in the case of we are still trying to complete the command name
    if (inputIsBeforeOption) {
      // workaround for SimpleParser bugs with "" option key, and spaces in option values
      int curs =
          completeSpecial(candidates, userInput, inputTokens, CliStrings.HELP, ConverterHint.HELP);
      if (curs > 0) {
        return curs;
      }
      curs =
          completeSpecial(candidates, userInput, inputTokens, CliStrings.HINT, ConverterHint.HINT);
      if (curs > 0) {
        return curs;
      }

      List<Completion> potentials = getCandidates(userInput);
      if (potentials.size() == 1 && potentials.get(0).getValue().equals(userInput)) {
        potentials = getCandidates(userInput.trim() + " ");
      }

      if (potentials.size() > 0) {
        candidates.addAll(potentials);
        return 0;
      }
      // otherwise, falling down to the potentials.size==0 case below
    }

    // now we are either trying to complete the option or a value
    // trying to get candidates using the converted input
    String buffer = getSimpleParserInputFromTokens(inputTokens);
    String lastToken = inputTokens.get(inputTokens.size() - 1);
    boolean lastTokenIsOption = lastToken.startsWith("--");
    // In the original user input, where to begin the candidate string for completion
    int candidateBeginAt;

    // initially assume we are trying to complete the last token
    List<Completion> potentials = getCandidates(buffer);

    // if the last token is already complete (or user deliberately ends with a space denoting the
    // last token is complete, then add either space or " --" and try again
    if (potentials.size() == 0 || userInput.endsWith(" ")) {
      candidateBeginAt = buffer.length();
      // last token is an option
      if (lastTokenIsOption) {
        // add a space to the buffer to get the option value candidates
        potentials = getCandidates(buffer + " ");
        lastTokenIsOption = false;
      }
      // last token is a value, we need to add " --" to it and retry to get the next list of options
      else {
        potentials = getCandidates(buffer + " --");
        lastTokenIsOption = true;
      }
    } else {
      if (lastTokenIsOption) {
        candidateBeginAt = buffer.length() - lastToken.length();
      } else {
        // need to return the index before the "=" sign, since later on we are going to add the
        // "=" sign to the completion candidates
        candidateBeginAt = buffer.length() - lastToken.length() - 1;
      }
    }

    // manipulate the candidate strings
    if (lastTokenIsOption) {
      // strip off the beginning part of the candidates from the cursor point
      potentials.replaceAll(
          completion -> new Completion(completion.getValue().substring(candidateBeginAt)));
    }
    // if the completed values are option, and the userInput doesn't ends with an "=" sign,
    else if (!userInput.endsWith("=")) {
      // these potentials do not have "=" in front of them, manually add them
      potentials.replaceAll(completion -> new Completion("=" + completion.getValue()));
    }

    candidates.addAll(potentials);

    // usually we want to return the cursor at candidateBeginAt, but since we consolidated
    // --J options into one, and added quotes around we need to consider the length difference
    // between userInput and the converted input
    cursor = candidateBeginAt + (userInput.trim().length() - buffer.length());
    return cursor;
  }

  /**
   * gets a specific String converter from the list of registered converters
   */
  private Converter<?> converterFor(String converterHint) {
    for (Converter<?> candidate : getConverters()) {
      if (candidate.supports(String.class, converterHint)) {
        return candidate;
      }
    }
    return null;
  }

  /**
   * uses a specific converter directly, bypassing the need to find it by the command's options
   */
  private int completeSpecial(List<Completion> candidates, String userInput,
      List<String> inputTokens, String cmd,
      String converterHint) {
    if (inputTokens.get(0).equals(cmd)) {
      String prefix = userInput.equals(cmd) ? " " : "";
      String existing = String.join(" ", inputTokens.subList(1, inputTokens.size())).toLowerCase();
      List<Completion> all = new ArrayList<>();
      Converter<?> converter = converterFor(converterHint);
      if (converter != null) {
        converter.getAllPossibleValues(all, null, null, null, null);
        candidates.addAll(all.stream().filter(c -> c.getValue().toLowerCase().startsWith(existing))
            .map(c -> new Completion(prefix + c.getValue()))
            .collect(Collectors.toList()));
        return Math.min(userInput.length(), cmd.length() + 1);
      }
    }
    return 0;
  }

  /**
   * @param buffer use the buffer to find the completion candidates
   *
   *        Note the cursor may not be the size the buffer
   */
  private List<Completion> getCandidates(String buffer) {
    List<Completion> candidates = new ArrayList<>();
    // always pass the buffer length as the cursor position for simplicity purpose
    super.completeAdvanced(buffer, buffer.length(), candidates);
    // trimming the candidates
    candidates.replaceAll(completion -> new Completion(completion.getValue().trim()));
    return candidates;
  }
}
