package org.checkerframework.checker.i18nformatter.util;

import java.text.ChoiceFormat;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.MessageFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.IllegalFormatException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.checkerframework.checker.i18nformatter.qual.I18nChecksFormat;
import org.checkerframework.checker.i18nformatter.qual.I18nConversionCategory;
import org.checkerframework.checker.i18nformatter.qual.I18nValidFormat;
import org.checkerframework.checker.interning.qual.InternedDistinct;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;
import org.checkerframework.framework.qual.AnnotatedFor;

/**
 * This class provides a collection of utilities to ease working with i18n format strings.
 *
 * @checker_framework.manual #i18n-formatter-checker Internationalization Format String Checker
 */
@AnnotatedFor("nullness")
public class I18nFormatUtil {

  /**
   * Throws an exception if the format is not syntactically valid.
   *
   * @param format the format string to parse
   */
  @SuppressWarnings("nullness:argument") // It's not documented, but passing null as the
  // argument array is supported.
  public static void tryFormatSatisfiability(String format) throws IllegalFormatException {
    MessageFormat.format(format, (Object[]) null);
  }

  /**
   * Returns a {@link I18nConversionCategory} for every conversion found in the format string.
   *
   * @param format the format string to parse
   * @throws IllegalFormatException if the format is not syntactically valid
   */
  public static I18nConversionCategory[] formatParameterCategories(String format)
      throws IllegalFormatException {
    tryFormatSatisfiability(format);
    I18nConversion[] cs = MessageFormatParser.parse(format);

    int maxIndex = -1;
    Map<Integer, I18nConversionCategory> conv = new HashMap<>(cs.length);

    for (I18nConversion c : cs) {
      int index = c.index;
      Integer indexKey = index;
      conv.put(
          indexKey,
          I18nConversionCategory.intersect(
              c.category,
              conv.containsKey(indexKey) ? conv.get(indexKey) : I18nConversionCategory.UNUSED));
      maxIndex = Math.max(maxIndex, index);
    }

    I18nConversionCategory[] res = new I18nConversionCategory[maxIndex + 1];
    for (int i = 0; i <= maxIndex; i++) {
      Integer indexKey = i;
      res[i] = conv.containsKey(indexKey) ? conv.get(indexKey) : I18nConversionCategory.UNUSED;
    }
    return res;
  }

  /**
   * Returns true if the format string is satisfiable, and if the format's parameters match the
   * passed {@link I18nConversionCategory}s. Otherwise an error is thrown.
   *
   * @param format a format string
   * @param cc a list of expected categories for the string's format specifiers
   * @return true if the format string's specifiers are the given categories, in order
   */
  // TODO introduce more such functions, see RegexUtil for examples
  @I18nChecksFormat
  public static boolean hasFormat(String format, I18nConversionCategory... cc) {
    I18nConversionCategory[] fcc = formatParameterCategories(format);
    if (fcc.length != cc.length) {
      return false;
    }

    for (int i = 0; i < cc.length; i++) {
      if (!I18nConversionCategory.isSubsetOf(cc[i], fcc[i])) {
        return false;
      }
    }
    return true;
  }

  @I18nValidFormat
  public static boolean isFormat(String format) {
    try {
      formatParameterCategories(format);
    } catch (Exception e) {
      return false;
    }
    return true;
  }

  /** An I18n conversion directive. */
  private static class I18nConversion {
    /** The index into the string. */
    public final int index;

    /** The conversion category. */
    public final I18nConversionCategory category;

    /**
     * Creates a new I18nConversion.
     *
     * @param index the index into the string
     * @param category the conversion category
     */
    public I18nConversion(int index, I18nConversionCategory category) {
      this.index = index;
      this.category = category;
    }

    @Override
    public String toString() {
      return category.toString() + "(index: " + index + ")";
    }
  }

  private static class MessageFormatParser {

    public static int maxOffset;

    /** The locale to use for formatting numbers and dates. Is set in {@link #parse}. */
    private static @MonotonicNonNull Locale locale;

    /** An array of formatters, which are used to format the arguments. Is set in {@link #parse}. */
    private static @MonotonicNonNull List<I18nConversionCategory> categories;

    /**
     * The argument numbers corresponding to each formatter. (The formatters are stored in the order
     * they occur in the pattern, not in the order in which the arguments are specified.) Is set in
     * {@link #parse}.
     */
    private static @MonotonicNonNull List<Integer> argumentIndices;

    // I think this means the number of format specifiers in the format string.
    /** The number of subformats. */
    private static int numFormat;

    // Indices for segments
    private static final int SEG_RAW = 0;
    private static final int SEG_INDEX = 1;
    private static final int SEG_TYPE = 2;
    private static final int SEG_MODIFIER = 3; // modifier or subformat

    // Indices for type keywords
    private static final int TYPE_NULL = 0;
    private static final int TYPE_NUMBER = 1;
    private static final int TYPE_DATE = 2;
    private static final int TYPE_TIME = 3;
    private static final int TYPE_CHOICE = 4;

    private static final String[] TYPE_KEYWORDS = {"", "number", "date", "time", "choice"};

    // Indices for number modifiers
    private static final int MODIFIER_DEFAULT = 0; // common in number and date-time
    private static final int MODIFIER_CURRENCY = 1;
    private static final int MODIFIER_PERCENT = 2;
    private static final int MODIFIER_INTEGER = 3;

    private static final String[] NUMBER_MODIFIER_KEYWORDS = {"", "currency", "percent", "integer"};

    private static final String[] DATE_TIME_MODIFIER_KEYWORDS = {
      "", "short", "medium", "long", "full"
    };

    @EnsuresNonNull({"categories", "argumentIndices", "locale"})
    public static I18nConversion[] parse(String pattern) {
      MessageFormatParser.categories = new ArrayList<>();
      MessageFormatParser.argumentIndices = new ArrayList<>();
      MessageFormatParser.locale = Locale.getDefault(Locale.Category.FORMAT);
      applyPattern(pattern);

      I18nConversion[] ret = new I18nConversion[MessageFormatParser.numFormat];
      for (int i = 0; i < MessageFormatParser.numFormat; i++) {
        ret[i] = new I18nConversion(argumentIndices.get(i), categories.get(i));
      }
      return ret;
    }

    @SuppressWarnings("nullness:dereference.of.nullable") // complex rules for segments[i]
    @RequiresNonNull({"argumentIndices", "categories", "locale"})
    private static void applyPattern(String pattern) {
      @Nullable StringBuilder[] segments = new StringBuilder[4];
      // Allocate only segments[SEG_RAW] here. The rest are
      // allocated on demand.
      segments[SEG_RAW] = new StringBuilder();

      int part = SEG_RAW;
      MessageFormatParser.numFormat = 0;
      boolean inQuote = false;
      int braceStack = 0;
      maxOffset = -1;
      for (int i = 0; i < pattern.length(); ++i) {
        char ch = pattern.charAt(i);
        if (part == SEG_RAW) {
          if (ch == '\'') {
            if (i + 1 < pattern.length() && pattern.charAt(i + 1) == '\'') {
              segments[part].append(ch); // handle doubles
              ++i;
            } else {
              inQuote = !inQuote;
            }
          } else if (ch == '{' && !inQuote) {
            part = SEG_INDEX;
            if (segments[SEG_INDEX] == null) {
              segments[SEG_INDEX] = new StringBuilder();
            }
          } else {
            segments[part].append(ch);
          }
        } else {
          if (inQuote) { // just copy quotes in parts
            segments[part].append(ch);
            if (ch == '\'') {
              inQuote = false;
            }
          } else {
            switch (ch) {
              case ',':
                if (part < SEG_MODIFIER) {
                  if (segments[++part] == null) {
                    segments[part] = new StringBuilder();
                  }
                } else {
                  segments[part].append(ch);
                }
                break;
              case '{':
                ++braceStack;
                segments[part].append(ch);
                break;
              case '}':
                if (braceStack == 0) {
                  part = SEG_RAW;
                  makeFormat(numFormat, segments);
                  numFormat++;
                  // throw away other segments
                  segments[SEG_INDEX] = null;
                  segments[SEG_TYPE] = null;
                  segments[SEG_MODIFIER] = null;
                } else {
                  --braceStack;
                  segments[part].append(ch);
                }
                break;
              case ' ':
                // Skip any leading space chars for SEG_TYPE.
                if (part != SEG_TYPE || segments[SEG_TYPE].length() > 0) {
                  segments[part].append(ch);
                }
                break;
              case '\'':
                inQuote = true;
                segments[part].append(ch);
                break;
              default:
                segments[part].append(ch);
                break;
            }
          }
        }
      }
      if (braceStack == 0 && part != 0) {
        maxOffset = -1;
        throw new IllegalArgumentException("Unmatched braces in the pattern");
      }
    }

    /** Side-effects {@code categories} field, adding to it an I18nConversionCategory. */
    @RequiresNonNull({"argumentIndices", "categories", "locale"})
    private static void makeFormat(int offsetNumber, @Nullable StringBuilder[] textSegments) {
      String[] segments = new String[textSegments.length];
      for (int i = 0; i < textSegments.length; i++) {
        StringBuilder oneseg = textSegments[i];
        segments[i] = (oneseg != null) ? oneseg.toString() : "";
      }

      // get the argument number
      int argumentNumber;
      try {
        argumentNumber = Integer.parseInt(segments[SEG_INDEX]); // always
        // unlocalized!
      } catch (NumberFormatException e) {
        throw new IllegalArgumentException(
            "can't parse argument number: " + segments[SEG_INDEX], e);
      }
      if (argumentNumber < 0) {
        throw new IllegalArgumentException("negative argument number: " + argumentNumber);
      }

      int oldMaxOffset = maxOffset;
      maxOffset = offsetNumber;
      argumentIndices.add(argumentNumber);

      // now get the format
      final I18nConversionCategory category;
      if (segments[SEG_TYPE].length() != 0) {
        int type = findKeyword(segments[SEG_TYPE], TYPE_KEYWORDS);
        switch (type) {
          case TYPE_NULL:
            category = I18nConversionCategory.GENERAL;
            break;
          case TYPE_NUMBER:
            switch (findKeyword(segments[SEG_MODIFIER], NUMBER_MODIFIER_KEYWORDS)) {
              case MODIFIER_DEFAULT:
              case MODIFIER_CURRENCY:
              case MODIFIER_PERCENT:
              case MODIFIER_INTEGER:
                break;
              default: // DecimalFormat pattern
                try {
                  new DecimalFormat(
                      segments[SEG_MODIFIER], DecimalFormatSymbols.getInstance(locale));
                } catch (IllegalArgumentException e) {
                  maxOffset = oldMaxOffset;
                  // invalid decimal subformat pattern
                  throw e;
                }
                break;
            }
            category = I18nConversionCategory.NUMBER;
            break;
          case TYPE_DATE:
          case TYPE_TIME:
            int mod = findKeyword(segments[SEG_MODIFIER], DATE_TIME_MODIFIER_KEYWORDS);
            if (mod >= 0 && mod < DATE_TIME_MODIFIER_KEYWORDS.length) {
              // nothing to do
            } else {
              // SimpleDateFormat pattern
              try {
                new SimpleDateFormat(segments[SEG_MODIFIER], locale);
              } catch (IllegalArgumentException e) {
                maxOffset = oldMaxOffset;
                // invalid date subformat pattern
                throw e;
              }
            }
            category = I18nConversionCategory.DATE;
            break;
          case TYPE_CHOICE:
            if (segments[SEG_MODIFIER].length() == 0) {
              throw new IllegalArgumentException(
                  "Choice Pattern requires Subformat Pattern: " + segments[SEG_MODIFIER]);
            }
            try {
              // ChoiceFormat pattern
              new ChoiceFormat(segments[SEG_MODIFIER]);
            } catch (Exception e) {
              maxOffset = oldMaxOffset;
              // invalid choice subformat pattern
              throw new IllegalArgumentException(
                  "Choice Pattern incorrect: " + segments[SEG_MODIFIER], e);
            }
            category = I18nConversionCategory.NUMBER;
            break;
          default:
            maxOffset = oldMaxOffset;
            throw new IllegalArgumentException("unknown format type: " + segments[SEG_TYPE]);
        }
      } else {
        category = I18nConversionCategory.GENERAL;
      }
      categories.add(category);
    }

    /**
     * Return the index of s in list. If not found, return the index of
     * s.trim().toLowerCase(Locale.ROOT) in list. If still not found, return -1.
     */
    private static int findKeyword(String s, String[] list) {
      for (int i = 0; i < list.length; ++i) {
        if (s.equals(list[i])) {
          return i;
        }
      }

      // Try trimmed lowercase.
      @SuppressWarnings("interning:assignment") // test if value changed
      @InternedDistinct String ls = s.trim().toLowerCase(Locale.ROOT);
      if (ls != s) { // Don't loop if the string trim().toLowerCase returned the same object.
        for (int i = 0; i < list.length; ++i) {
          if (ls.equals(list[i])) {
            return i;
          }
        }
      }
      return -1;
    }
  }
}
