/*
 * Copyright 2008 Google Inc.
 * 
 * Licensed 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 com.google.gwt.i18n.rebind;

import static com.google.gwt.i18n.rebind.AnnotationUtil.getClassAnnotation;

import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.typeinfo.JArrayType;
import com.google.gwt.core.ext.typeinfo.JClassType;
import com.google.gwt.core.ext.typeinfo.JMethod;
import com.google.gwt.core.ext.typeinfo.JParameter;
import com.google.gwt.core.ext.typeinfo.JParameterizedType;
import com.google.gwt.core.ext.typeinfo.JPrimitiveType;
import com.google.gwt.core.ext.typeinfo.JRawType;
import com.google.gwt.core.ext.typeinfo.JType;
import com.google.gwt.i18n.client.Constants.DefaultBooleanValue;
import com.google.gwt.i18n.client.Constants.DefaultDoubleValue;
import com.google.gwt.i18n.client.Constants.DefaultFloatValue;
import com.google.gwt.i18n.client.Constants.DefaultIntValue;
import com.google.gwt.i18n.client.Constants.DefaultStringArrayValue;
import com.google.gwt.i18n.client.Constants.DefaultStringMapValue;
import com.google.gwt.i18n.client.Constants.DefaultStringValue;
import com.google.gwt.i18n.client.LocalizableResource.DefaultLocale;
import com.google.gwt.i18n.client.LocalizableResource.Description;
import com.google.gwt.i18n.client.LocalizableResource.GenerateKeys;
import com.google.gwt.i18n.client.LocalizableResource.Key;
import com.google.gwt.i18n.client.LocalizableResource.Meaning;
import com.google.gwt.i18n.client.Messages.AlternateMessage;
import com.google.gwt.i18n.client.Messages.DefaultMessage;
import com.google.gwt.i18n.client.Messages.Example;
import com.google.gwt.i18n.client.Messages.Optional;
import com.google.gwt.i18n.client.Messages.PluralCount;
import com.google.gwt.i18n.client.Messages.Select;
import com.google.gwt.i18n.client.PluralRule;
import com.google.gwt.i18n.server.KeyGenerator;
import com.google.gwt.i18n.server.Message;
import com.google.gwt.i18n.server.MessageInterface;
import com.google.gwt.i18n.server.MessageUtils;
import com.google.gwt.i18n.server.MessageUtils.KeyGeneratorException;
import com.google.gwt.i18n.shared.GwtLocale;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

/**
 * AbstractResource implementation which looks up text annotations on classes.
 */
@SuppressWarnings("deprecation")
public class AnnotationsResource extends AbstractResource {

  /**
   * An exception indicating there was some problem with an annotation.
   * 
   * A caller receiving this exception should log the human-readable message and
   * treat it as a fatal error.
   */
  public static class AnnotationsError extends Exception {

    public AnnotationsError(String msg) {
      super(msg);
    }
  }

  /**
   * Class for argument information, used for export.
   */
  public static class ArgumentInfo {

    public String example;
    public boolean isPluralCount;
    public String name;
    public boolean optional;
    public Class<? extends PluralRule> pluralRuleClass;
    public boolean isSelect;

    public ArgumentInfo(String name) {
      this.name = name;
    }
  }

  private static class EntryWrapper implements ResourceEntry {

    private final String key;
    private final MethodEntry entry;

    public EntryWrapper(String key, MethodEntry entry) {
      this.key = key;
      this.entry = entry;
    }

    public String getForm(String form) {
      return form == null ? entry.text : entry.altText.get(form);
    }

    public Collection<String> getForms() {
      return entry.altText.keySet();
    }

    public String getKey() {
      return key;
    }
  }

  /**
   * Class to keep annotation information about a particular method.
   */
  private static class MethodEntry {

    // Strings used in toString for formatting.
    private static final String DETAILS_PREFIX = " (";
    private static final String DETAILS_SEPARATOR = ", ";

    public ArrayList<ArgumentInfo> arguments;
    public String description;
    public String meaning;
    public Map<String, String> altText;
    public String text;

    public MethodEntry(String text, String meaning) {
      this.text = text;
      this.meaning = meaning;
      altText = new HashMap<String, String>();
      arguments = new ArrayList<ArgumentInfo>();
    }

    public void addAlternateText(String altForm, String altMessage) {
      altText.put(altForm, altMessage);
    }

    public ArgumentInfo addArgument(String argName) {
      ArgumentInfo argInfo = new ArgumentInfo(argName);
      arguments.add(argInfo);
      return argInfo;
    }

    @Override
    public String toString() {
      StringBuilder buf = new StringBuilder();
      buf.append(text);
      String prefix = DETAILS_PREFIX;
      if (meaning != null) {
        buf.append(prefix).append("meaning=").append(meaning);
        prefix = DETAILS_SEPARATOR;
      }
      if (description != null) {
        buf.append(prefix).append("desc=").append(description);
        prefix = DETAILS_SEPARATOR;
      }
      if (DETAILS_SEPARATOR == prefix) {
        buf.append(')');
      }
      return buf.toString();
    }
  }

  /**
   * Returns the key for a given method.
   *
   * If null is returned, an error message has already been logged.
   *
   * @param logger 
   * @param keyGenerator 
   * @param method 
   * @param isConstants 
   * @return null if unable to get or compute the key for this method, otherwise
   *         the key is returned
   */
  public static String getKey(TreeLogger logger, KeyGenerator keyGenerator,
      JMethod method, boolean isConstants) {
    Key key = method.getAnnotation(Key.class);
    if (key != null) {
      return key.value();
    }
    String text;
    try {
      text = getTextString(method, null, isConstants);
    } catch (AnnotationsError e) {
      return null;
    }
    if (keyGenerator == null) {
      // Gracefully handle the case of an invalid KeyGenerator classname
      return null;
    }
    MessageInterface msgIntf = new KeyGenMessageInterface(
        method.getEnclosingType());
    Message msg = new KeyGenMessage(method);
    String keyStr = keyGenerator.generateKey(msg);
    if (keyStr == null) {
      if (text == null) {
        logger.log(
            TreeLogger.ERROR,
            "Key generator "
                + keyGenerator.getClass().getName()
                + " requires the default value be specified in an annotation for method "
                + method.getName(), null);
      } else {
        logger.log(TreeLogger.ERROR, "Key generator "
            + keyGenerator.getClass().getName()
            + " was unable to compute a key value for method "
            + method.getName(), null);
      }
    }
    return keyStr;
  }

  /**
   * Returns a suitable key generator for the specified class.
   * 
   * @param targetClass 
   * @return KeyGenerator instance, guaranteed to not be null 
   * @throws AnnotationsError if a specified KeyGenerator cannot be created
   */
  public static KeyGenerator getKeyGenerator(JClassType targetClass)
      throws AnnotationsError {
    GenerateKeys generator = getClassAnnotation(targetClass, GenerateKeys.class);
    try {
      return MessageUtils.getKeyGenerator(generator);
    } catch (KeyGeneratorException e) {
      throw new AnnotationsError(e.getMessage());
    }
  }

  /**
   * Return the text string from annotations for a particular method.
   * 
   * @param method the method to retrieve text
   * @param map if not null, add keys for DefaultStringMapValue to this map
   * @param isConstants true if the method is in a subinterface of Constants
   * @throws AnnotationsError if the annotation usage is incorrect
   * @return the text value to use for this method, as if read from a properties
   *         file, or null if there are no annotations.
   */
  private static String getTextString(JMethod method,
      Map<String, MethodEntry> map, boolean isConstants)
      throws AnnotationsError {
    JType returnType = method.getReturnType();
    DefaultMessage defaultText = method.getAnnotation(DefaultMessage.class);
    DefaultStringValue stringValue = method.getAnnotation(DefaultStringValue.class);
    DefaultStringArrayValue stringArrayValue = method.getAnnotation(DefaultStringArrayValue.class);
    DefaultStringMapValue stringMapValue = method.getAnnotation(DefaultStringMapValue.class);
    DefaultIntValue intValue = method.getAnnotation(DefaultIntValue.class);
    DefaultFloatValue floatValue = method.getAnnotation(DefaultFloatValue.class);
    DefaultDoubleValue doubleValue = method.getAnnotation(DefaultDoubleValue.class);
    DefaultBooleanValue booleanValue = method.getAnnotation(DefaultBooleanValue.class);
    int constantsCount = 0;
    if (stringValue != null) {
      constantsCount++;
      if (!returnType.getQualifiedSourceName().equals("java.lang.String")) {
        throw new AnnotationsError(
            "@DefaultStringValue can only be used with a method returning String");
      }
    }
    if (stringArrayValue != null) {
      constantsCount++;
      JArrayType arrayType = returnType.isArray();
      if (arrayType == null
          || !arrayType.getComponentType().getQualifiedSourceName().equals(
              "java.lang.String")) {
        throw new AnnotationsError(
            "@DefaultStringArrayValue can only be used with a method returning String[]");
      }
    }
    if (stringMapValue != null) {
      constantsCount++;
      JRawType rawType = returnType.getErasedType().isRawType();
      boolean error = false;
      if (rawType == null
          || !rawType.getQualifiedSourceName().equals("java.util.Map")) {
        error = true;
      } else {
        JParameterizedType paramType = returnType.isParameterized();
        if (paramType != null) {
          JType[] args = paramType.getTypeArgs();
          if (args.length != 2
              || !args[0].getQualifiedSourceName().equals("java.lang.String")
              || !args[1].getQualifiedSourceName().equals("java.lang.String")) {
            error = true;
          }
        }
      }
      if (error) {
        throw new AnnotationsError(
            "@DefaultStringMapValue can only be used with a method "
                + "returning Map or Map<String,String>");
      }
    }
    if (intValue != null) {
      constantsCount++;
      JPrimitiveType primType = returnType.isPrimitive();
      if (primType != JPrimitiveType.INT) {
        throw new AnnotationsError(
            "@DefaultIntValue can only be used with a method returning int");
      }
    }
    if (floatValue != null) {
      constantsCount++;
      JPrimitiveType primType = returnType.isPrimitive();
      if (primType != JPrimitiveType.FLOAT) {
        throw new AnnotationsError(
            "@DefaultFloatValue can only be used with a method returning float");
      }
    }
    if (doubleValue != null) {
      constantsCount++;
      JPrimitiveType primType = returnType.isPrimitive();
      if (primType != JPrimitiveType.DOUBLE) {
        throw new AnnotationsError(
            "@DefaultDoubleValue can only be used with a method returning double");
      }
    }
    if (booleanValue != null) {
      constantsCount++;
      JPrimitiveType primType = returnType.isPrimitive();
      if (primType != JPrimitiveType.BOOLEAN) {
        throw new AnnotationsError(
            "@DefaultBooleanValue can only be used with a method returning boolean");
      }
    }
    if (!isConstants) {
      if (constantsCount > 0) {
        throw new AnnotationsError(
            "@Default*Value is not permitted on a Messages interface; see @DefaultMessage");
      }
      if (defaultText != null) {
        return defaultText.value();
      }
    } else {
      if (defaultText != null) {
        throw new AnnotationsError(
            "@DefaultMessage is not permitted on a Constants interface; see @Default*Value");
      }
      if (constantsCount > 1) {
        throw new AnnotationsError(
            "No more than one @Default*Value annotation may be used on a method");
      }
      if (stringValue != null) {
        return stringValue.value();
      } else if (intValue != null) {
        return Integer.toString(intValue.value());
      } else if (floatValue != null) {
        return Float.toString(floatValue.value());
      } else if (doubleValue != null) {
        return Double.toString(doubleValue.value());
      } else if (booleanValue != null) {
        return Boolean.toString(booleanValue.value());
      } else if (stringArrayValue != null) {
        StringBuilder buf = new StringBuilder();
        boolean firstString = true;
        for (String str : stringArrayValue.value()) {
          str = str.replace("\\", "\\\\");
          str = str.replace(",", "\\,");
          if (!firstString) {
            buf.append(',');
          } else {
            firstString = false;
          }
          buf.append(str);
        }
        return buf.toString();
      } else if (stringMapValue != null) {
        StringBuilder buf = new StringBuilder();
        boolean firstString = true;
        String[] entries = stringMapValue.value();
        if ((entries.length & 1) != 0) {
          throw new AnnotationsError(
              "Odd number of strings supplied to @DefaultStringMapValue");
        }
        for (int i = 0; i < entries.length; i += 2) {
          String key = entries[i];
          String value = entries[i + 1];

          if (map != null) {
            // add key=value part to map
            MethodEntry entry = new MethodEntry(value, null);
            map.put(key, entry);
          }

          // add the key to the master entry
          key = key.replace("\\", "\\\\");
          key = key.replace(",", "\\,");
          if (!firstString) {
            buf.append(',');
          } else {
            firstString = false;
          }
          buf.append(key);
        }
        return buf.toString();
      }
    }
    return null;
  }

  private Map<String, MethodEntry> map;

  /**
   * Create a resource that supplies data from i18n-related annotations.
   * 
   * @param logger
   * @param clazz
   * @param locale
   * @param isConstants
   * @throws AnnotationsError if there is a fatal error while processing
   *           annotations
   */
  public AnnotationsResource(TreeLogger logger, JClassType clazz,
      GwtLocale locale, boolean isConstants) throws AnnotationsError {
    super(locale);
    KeyGenerator keyGenerator = getKeyGenerator(clazz);
    map = new HashMap<String, MethodEntry>();
    setPath(clazz.getQualifiedSourceName());
    String defLocaleValue = null;
    
    // If the class has an embedded locale in it, use that for the default
    String className = clazz.getSimpleSourceName();
    int underscore = className.indexOf('_');
    if (underscore >= 0) {
      defLocaleValue = className.substring(underscore + 1);
    }
    
    // If there is an annotation declaring the default locale, use that
    DefaultLocale defLocaleAnnot = getClassAnnotation(clazz,
        DefaultLocale.class);
    if (defLocaleAnnot != null) {
      defLocaleValue = defLocaleAnnot.value();
    }
    GwtLocale defLocale = LocaleUtils.getLocaleFactory().fromString(
        defLocaleValue);
    if (!locale.isDefault() && !locale.equals(defLocale)) {
      logger.log(TreeLogger.WARN, "Default locale " + defLocale + " on "
          + clazz.getQualifiedSourceName() + " doesn't match " + locale);
      return;
    }
    matchLocale = defLocale;
    for (JMethod method : clazz.getMethods()) {
      String meaningString = null;
      Meaning meaning = method.getAnnotation(Meaning.class);
      if (meaning != null) {
        meaningString = meaning.value();
      }
      String textString = getTextString(method, map, isConstants);
      if (textString == null) {
        // ignore ones without some value annotation
        continue;
      }
      String key = null;
      Key keyAnnot = method.getAnnotation(Key.class);
      if (keyAnnot != null) {
        key = keyAnnot.value();
      } else {
        Message msg = new KeyGenMessage(method);
        key = keyGenerator.generateKey(msg);
        if (key == null) {
          throw new AnnotationsError("Could not compute key for "
              + method.getEnclosingType().getQualifiedSourceName() + "."
              + method.getName() + " using " + keyGenerator);
        }
      }
      MethodEntry entry = new MethodEntry(textString, meaningString);
      map.put(key, entry);
      Description description = method.getAnnotation(Description.class);
      if (description != null) {
        entry.description = description.value();
      }
      // use full name to avoid deprecation warnings in the imports
      com.google.gwt.i18n.client.Messages.PluralText pluralText = method
          .getAnnotation(com.google.gwt.i18n.client.Messages.PluralText.class);
      if (pluralText != null) {
        String[] pluralForms = pluralText.value();
        if ((pluralForms.length & 1) != 0) {
          throw new AnnotationsError(
              "Odd number of strings supplied to @PluralText: must be"
              + " pairs of form names and messages");
        }
        for (int i = 0; i + 1 < pluralForms.length; i += 2) {
          entry.addAlternateText(pluralForms[i], pluralForms[i + 1]);
        }
      }
      AlternateMessage altMsg = method.getAnnotation(AlternateMessage.class);
      if (altMsg != null) {
        if (pluralText != null) {
          throw new AnnotationsError("May not have both @AlternateMessage"
              + " and @PluralText");
        }
        String[] altForms = altMsg.value();
        if ((altForms.length & 1) != 0) {
          throw new AnnotationsError(
              "Odd number of strings supplied to @AlternateMessage: must be"
              + " pairs of values and messages");
        }
        for (int i = 0; i + 1 < altForms.length; i += 2) {
          entry.addAlternateText(altForms[i], altForms[i + 1]);
        }
      }
      for (JParameter param : method.getParameters()) {
        ArgumentInfo argInfo = entry.addArgument(param.getName());
        Optional optional = param.getAnnotation(Optional.class);
        if (optional != null) {
          argInfo.optional = true;
        }
        PluralCount pluralCount = param.getAnnotation(PluralCount.class);
        if (pluralCount != null) {
          argInfo.isPluralCount = true;
        }
        Example example = param.getAnnotation(Example.class);
        if (example != null) {
          argInfo.example = example.value();
        }
        Select select = param.getAnnotation(Select.class);
        if (select != null) {
          argInfo.isSelect = true;
        }
      }
    }
  }

  @Override
  public void addToKeySet(Set<String> s) {
    s.addAll(map.keySet());
  }

  public Iterable<ArgumentInfo> argumentsIterator(String key) {
    MethodEntry entry = map.get(key);
    return entry != null ? entry.arguments : null;
  }

  public String getDescription(String key) {
    MethodEntry entry = map.get(key);
    return entry == null ? null : entry.description;
  }

  @Override
  public ResourceEntry getEntry(String key) {
    MethodEntry entry = map.get(key);
    return entry == null ? null : new EntryWrapper(key, entry);
  }

  @Override
  public Collection<String> getExtensions(String key) {
    MethodEntry entry = map.get(key);
    return entry == null ? new ArrayList<String>() : entry.altText.keySet();
  }

  public String getMeaning(String key) {
    MethodEntry entry = map.get(key);
    return entry == null ? null : entry.meaning;
  }

  @Override
  public String getStringExt(String key, String extension) {
    MethodEntry entry = map.get(key);
    if (entry == null) {
      return null;
    }
    if (extension != null) {
      return entry.altText.get(extension);
    } else {
      return entry.text;
    }
  }

  @Override
  public boolean notEmpty() {
    return !map.isEmpty();
  }

  @Override
  public String toString() {
    return "Annotations from class " + getPath() + " @" + getMatchLocale();
  }
}
