public final class ConfigMacroUtil {

  /** Setup logging for the {@link ConfigMacroUtil}. */
  private static final Logger logger = LoggerFactory.getLogger(ConfigMacroUtil.class);

  private static final DateUtil DATE_UTIL = DateUtil.getDateUtil(ServerConstants.TIME_ZONE_UTC);

  private ConfigMacroUtil() {}

  /**
   * Expands any interpolation contained within the JsonValue object in-place.
   *
   * @param json JsonValue to parse for macros
   */
  public static void expand(JsonValue json) {
    Iterator<String> iter = json.keys().iterator();
    while (iter.hasNext()) {
      String key = iter.next();

      String expanded = parse(json.get(key));
      if (expanded != null) {
        json.put(key, expanded);
      }
    }
  }

  /**
   * Start the string parsing. If base is not a string, will return null.
   *
   * @param base base JsonValue object to begin parsing from
   * @return a string with any interpolation expanded or null if base is not a string
   */
  private static String parse(JsonValue base) {
    if (!base.isString()) {
      return null;
    }

    return buildString(base.asString());
  }

  /**
   * Begins building the string from interpolation and normal string contents.
   *
   * @param str string to interpolate from
   * @return a string after interpolation
   */
  public static String buildString(String str) {
    StringBuilder builder = new StringBuilder();

    List<Integer> possibleLocations = possibleLocations(str);
    if (possibleLocations.isEmpty()) {
      return null;
    }

    List<Integer[]> confirmedLocations = confirmedLocations(str, possibleLocations);
    if (confirmedLocations.isEmpty()) {
      return null;
    }

    int lastEnd = 0;
    for (Integer[] pair : confirmedLocations) {
      int start = pair[0];
      int length = pair[1];
      int end = start + length;
      builder.append(str.substring(lastEnd, start));
      builder.append(interpolate(str.substring(start, end)));
      lastEnd = pair[0] + pair[1];
    }

    return builder.toString();
  }

  /**
   * Identifies any possible interpolation locations (begins by looking for "${").
   *
   * @param str string to look through for interpolation sites
   * @return list of indices where the interpolation sites begin
   */
  private static List<Integer> possibleLocations(String str) {
    List<Integer> possibleLocations = new ArrayList<Integer>();

    int lastIndex = -1;
    int index;
    while ((index = str.indexOf("${", lastIndex + 1)) >= 0) {
      if (lastIndex == index) {
        break;
      }
      possibleLocations.add(index);
      lastIndex = index;
    }

    return possibleLocations;
  }

  /**
   * Confirm interpolation sites by looking for a closing brace.
   *
   * @param str string to confirm interpolation sites from
   * @param possibleLocations list of string indicies indicating possible interpolation beginnings
   * @return list paired integers containing (starting location, length of interpolation string)
   */
  private static List<Integer[]> confirmedLocations(String str, List<Integer> possibleLocations) {
    List<Integer[]> confirmedLocations = new ArrayList<Integer[]>();

    Integer[] lastPair = {-1, -1};
    for (Integer start : possibleLocations) {
      int length = 0;

      // Ignore any escaped \${}
      if (start != 0 && str.charAt(start - 1) == '\\') {
        continue;
      }

      // Determine the length and existence of a ${} block
      boolean found = false;
      for (int i = start; i < str.length(); i++) {
        length += 1;
        if (str.charAt(i) == '}') {
          found = true;
          break;
        }
      }

      // Don't add overlapping pairs -- this will keep "${ ${hi} }" from
      // being interpolated
      // technically it will wind up "${ ${hi}"
      if ((lastPair[0] + lastPair[1] < start) && found) {
        Integer[] pair = {start, length};
        confirmedLocations.add(pair);
      }
    }

    return confirmedLocations;
  }

  /**
   * Interpolates the macros contained in the interpolation braces.
   *
   * <p><b>NOTE:</b> for ease of tokenization, this expects each token to have a space between each
   * component <b><i>e.g.</i></b> "Time.now + 1d" rather than "Time.now+1d" <br>
   * <br>
   * <b>TODO:</b> Proper tokenizing
   *
   * @param str interpolation string
   * @return interpolated string
   */
  private static String interpolate(String str) {
    String toInterpolate = str.substring(2, str.length() - 1); // Strip ${
    // and }
    List<String> tokens = Arrays.asList(toInterpolate.split(" "));

    StringBuilder builder = new StringBuilder();
    Iterator<String> iter = tokens.iterator();
    while (iter.hasNext()) {
      String token = iter.next();

      if (token.equals("Time.now")) {
        builder.append(handleTime(tokens, iter));
      } else {
        logger.warn("Unrecognized token: {}", token);
        builder.append(token);
      }
    }

    return builder.toString();
  }

  /**
   * Handles the Time.now macro
   *
   * @param tokens list of tokens
   * @param iter iterator used to iterate over the list of tokens
   * @return string containing the interpolated time token
   */
  private static String handleTime(List<String> tokens, Iterator<String> iter) {
    DateTime dt = new DateTime();

    // Add some amount
    if (iter.hasNext()) {
      String operationToken = iter.next();
      if (operationToken.equals("+") || operationToken.equals("-")) {
        if (iter.hasNext()) {
          String quantityToken = iter.next(); // Get the magnitude to
          // add or subtract

          ReadablePeriod period = getTimePeriod(quantityToken);

          if (operationToken.equals("-")) {
            dt = dt.minus(period);
          } else {
            dt = dt.plus(period);
          }
        } else {
          logger.warn("Token '{}' not followed by a quantity", operationToken);
        }
      } else {
        logger.warn("Invalid token '{}', must be operator '+' or '-'", operationToken);
      }
    }

    return DATE_UTIL.formatDateTime(dt);
  }

  /**
   * Defines the magnitudes that can be added to the timestamp
   *
   * @param token token of form "[number][magnitude]" (ex. "1d")
   * @return integer indicating the magnitude of the date for the calendar system
   */
  public static ReadablePeriod getTimePeriod(String token) {
    String valString = token.substring(0, token.length() - 1);
    int value = Integer.parseInt(valString);
    char mag = token.charAt(token.length() - 1);

    ReadablePeriod period;

    switch (mag) {
      case 's':
        period = Seconds.seconds(value);
        break;
      case 'm':
        period = Minutes.minutes(value);
        break;
      case 'h':
        period = Hours.hours(value);
        break;
      case 'd':
        period = Days.days(value);
        break;
      case 'M':
        period = Months.months(value);
        break;
      case 'y':
        period = Years.years(value);
        break;
      default:
        logger.warn("Invalid date magnitude: {}. Defaulting to seconds.", mag);
        period = Seconds.seconds(value);
        break;
    }

    return period;
  }
}