/*
 * 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.user.rebind.ui;

import com.google.gwt.core.ext.Generator;
import com.google.gwt.core.ext.Generator.RunsLocal;
import com.google.gwt.core.ext.GeneratorContext;
import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.UnableToCompleteException;
import com.google.gwt.core.ext.impl.ResourceLocatorImpl;
import com.google.gwt.core.ext.typeinfo.JClassType;
import com.google.gwt.core.ext.typeinfo.JMethod;
import com.google.gwt.core.ext.typeinfo.NotFoundException;
import com.google.gwt.core.ext.typeinfo.TypeOracle;
import com.google.gwt.thirdparty.guava.common.annotations.VisibleForTesting;
import com.google.gwt.user.client.ui.ImageBundle;
import com.google.gwt.user.client.ui.ImageBundle.Resource;
import com.google.gwt.user.rebind.ClassSourceFileComposerFactory;
import com.google.gwt.user.rebind.SourceWriter;

import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;

/**
 * Generates an implementation of a user-defined interface <code>T</code> that
 * extends {@link com.google.gwt.user.client.ui.ImageBundle}.
 *
 * Each method in <code>T</code> must be declared to return
 * {@link com.google.gwt.user.client.ui.AbstractImagePrototype}, take no
 * parameters, and optionally specify the metadata tag <code>gwt.resource</code>
 * as the name of an image that can be found in the classpath. In the absence of
 * the metatadata tag, the method name with an extension of
 * <code>.png, .jpg, or .gif</code> defines the name of the image, and the
 * image file must be located in the same package as <code>T</code>.
 */
@RunsLocal
public class ImageBundleGenerator extends Generator {

  /**
   * Simple wrapper around JMethod that allows for unit test mocking.
   */
  interface JMethodOracle {

    @SuppressWarnings("deprecation")
    Resource getAnnotation(Class<Resource> clazz);

    String getName();

    String getPackageName();
  }

  /**
   * Indirection around the act of looking up a resource that allows for unit
   * test mocking.
   */
  @VisibleForTesting
  interface ResourceLocator {
    /**
     *
     * @param resName the resource name in a format that could be passed to
     *          <code>ClassLoader.getResource()</code>
     * @return <code>true</code> if the resource is present
     */
    boolean isResourcePresent(String resName);
  }

  private static class JMethodOracleImpl implements JMethodOracle {
    private final JMethod delegate;

    public JMethodOracleImpl(JMethod delegate) {
      this.delegate = delegate;
    }

    @Override
    @SuppressWarnings("deprecation")
    public Resource getAnnotation(Class<Resource> clazz) {
      return delegate.getAnnotation(clazz);
    }

    @Override
    public String getName() {
      return delegate.getName();
    }

    @Override
    public String getPackageName() {
      return delegate.getEnclosingType().getPackage().getName();
    }
  }

  @VisibleForTesting
  static final String MSG_NO_FILE_BASED_ON_METHOD_NAME = "No matching image resource was found; "
      + "any of the following filenames would have matched had they been present:";

  private static final String ABSTRACTIMAGEPROTOTYPE_QNAME = "com.google.gwt.user.client.ui.AbstractImagePrototype";

  private static final String CLIPPEDIMAGEPROTOTYPE_QNAME = "com.google.gwt.user.client.ui.impl.ClippedImagePrototype";

  private static final String GWT_QNAME = "com.google.gwt.core.client.GWT";

  private static final String[] IMAGE_FILE_EXTENSIONS = {"png", "gif", "jpg"};

  private static final String IMAGEBUNDLE_QNAME = "com.google.gwt.user.client.ui.ImageBundle";

  @VisibleForTesting
  static String msgCannotFindImageFromMetaData(String imgResName) {
    return "Unable to find image resource '" + imgResName + "'";
  }

  private final ResourceLocator resLocator;

  private GeneratorContext context;

  private TreeLogger logger;

  /**
   * Default constructor for image bundle. Locates resources using this class's
   * own class loader.
   */
  public ImageBundleGenerator() {
    this.resLocator = new ResourceLocator() {
      @Override
      public boolean isResourcePresent(String resName) {
        return ResourceLocatorImpl.tryFindResourceUrl(logger, context.getResourcesOracle(), resName)
            != null;
      }
    };
  }

  /**
   * Default access so that it can be accessed by unit tests.
   */
  @VisibleForTesting
  ImageBundleGenerator(ResourceLocator resourceLocator) {
    assert (resourceLocator != null);
    this.resLocator = resourceLocator;
  }

  @Override
  public String generate(TreeLogger logger, GeneratorContext context,
      String typeName) throws UnableToCompleteException {
    this.logger = logger;
    this.context = context;

    TypeOracle typeOracle = context.getTypeOracle();

    // Get metadata describing the user's class.
    JClassType userType = getValidUserType(logger, typeName, typeOracle);

    // Write the new class.
    JMethod[] imgMethods = userType.getOverridableMethods();
    String resultName = generateImplClass(logger, context, userType, imgMethods);

    // Return the complete name of the generated class.
    return resultName;
  }

  /**
   * Gets the resource name of the image associated with the specified image
   * bundle method in a form that can be passed to
   * <code>ClassLoader.getResource()</code>.
   *
   * @param logger the main logger
   * @param method the image bundle method whose image name is being sought
   * @return a resource name that is suitable to be passed into
   *         <code>ClassLoader.getResource()</code>; never returns
   *         <code>null</code>
   * @throws UnableToCompleteException thrown if a resource was specified but
   *           could not be found on the classpath
   */
  @VisibleForTesting
  String getImageResourceName(TreeLogger logger,
      JMethodOracle method) throws UnableToCompleteException {
    String imgName = tryGetImageNameFromMetaData(logger, method);
    if (imgName != null) {
      return imgName;
    } else {
      return getImageNameFromMethodName(logger, method);
    }
  }

  private String computeSubclassName(JClassType userType) {
    String baseName = userType.getName().replace('.', '_');
    return baseName + "_generatedBundle";
  }

  private void generateImageMethod(ImageBundleBuilder compositeImage,
      SourceWriter sw, JMethod method, String imgResName) {

    String decl = method.getReadableDeclaration(false, true, true, true, true);

    {
      sw.indent();

      // Create a singleton that this method can return. There is no need to
      // create a new instance every time this method is called, since
      // ClippedImagePrototype is immutable

      ImageBundleBuilder.ImageRect imageRect = compositeImage.getMapping(imgResName);
      String singletonName = method.getName() + "_SINGLETON";

      sw.print("private static final ClippedImagePrototype ");
      sw.print(singletonName);
      sw.print(" = new ClippedImagePrototype(IMAGE_BUNDLE_URL, ");
      sw.print(Integer.toString(imageRect.getLeft()));
      sw.print(", ");
      sw.print(Integer.toString(imageRect.getTop()));
      sw.print(", ");
      sw.print(Integer.toString(imageRect.getWidth()));
      sw.print(", ");
      sw.print(Integer.toString(imageRect.getHeight()));
      sw.println(");");

      sw.print(decl);
      sw.println(" {");

      {
        sw.indent();
        sw.print("return ");
        sw.print(singletonName);
        sw.println(";");
        sw.outdent();
      }

      sw.println("}");
      sw.outdent();
    }
  }

  /**
   * Generates the image bundle implementation class, checking each method for
   * validity as it is encountered.
   */
  private String generateImplClass(TreeLogger logger, GeneratorContext context,
      JClassType userType, JMethod[] imageMethods)
      throws UnableToCompleteException {
    // Lookup the type info for AbstractImagePrototype so that we can check for
    // the proper return type
    // on image bundle methods.
    final JClassType abstractImagePrototype;
    try {
      abstractImagePrototype = userType.getOracle().getType(
          ABSTRACTIMAGEPROTOTYPE_QNAME);
    } catch (NotFoundException e) {
      logger.log(TreeLogger.ERROR, "GWT " + ABSTRACTIMAGEPROTOTYPE_QNAME
          + " class is not available", e);
      throw new UnableToCompleteException();
    }

    // Compute the package and class names of the generated class.
    String pkgName = userType.getPackage().getName();
    String subName = computeSubclassName(userType);

    // Begin writing the generated source.
    ClassSourceFileComposerFactory f = new ClassSourceFileComposerFactory(
        pkgName, subName);
    f.addImport(ABSTRACTIMAGEPROTOTYPE_QNAME);
    f.addImport(CLIPPEDIMAGEPROTOTYPE_QNAME);
    f.addImport(GWT_QNAME);
    f.addImplementedInterface(userType.getQualifiedSourceName());

    PrintWriter pw = context.tryCreate(logger, pkgName, subName);
    if (pw != null) {
      SourceWriter sw = f.createSourceWriter(context, pw);

      // Build a compound image from each individual image.
      ImageBundleBuilder bulder = new ImageBundleBuilder();

      // Store the computed image names so that we don't have to lookup them up
      // again.
      List<String> imageResNames = new ArrayList<String>();

      for (JMethod method : imageMethods) {
        String branchMsg = "Analyzing method '" + method.getName()
            + "' in type " + userType.getQualifiedSourceName();
        TreeLogger branch = logger.branch(TreeLogger.DEBUG, branchMsg, null);

        // Verify that this method is valid on an image bundle.
        if (method.getReturnType() != abstractImagePrototype) {
          branch.log(TreeLogger.ERROR, "Return type must be "
              + ABSTRACTIMAGEPROTOTYPE_QNAME, null);
          throw new UnableToCompleteException();
        }

        if (method.getParameters().length > 0) {
          branch.log(TreeLogger.ERROR, "Method must have zero parameters", null);
          throw new UnableToCompleteException();
        }

        // Find the associated imaged resource.
        String imageResName = getImageResourceName(branch,
            new JMethodOracleImpl(method));
        assert (imageResName != null);
        imageResNames.add(imageResName);
        bulder.assimilate(logger, imageResName);
      }

      // Write the compound image into the output directory.
      String bundledImageUrl = bulder.writeBundledImage(logger, context);

      // Emit a constant for the composite URL. Note that we prepend the
      // module's base URL so that the module can reference its own resources
      // independently of the host HTML page.
      sw.print("private static final String IMAGE_BUNDLE_URL = GWT.getModuleBaseURL() + \"");
      sw.print(escape(bundledImageUrl));
      sw.println("\";");

      // Generate an implementation of each method.
      int imageResNameIndex = 0;
      for (JMethod method : imageMethods) {
        generateImageMethod(bulder, sw, method,
            imageResNames.get(imageResNameIndex++));
      }

      // Finish.
      sw.commit(logger);
    }

    return f.getCreatedClassName();
  }

  /**
   * Attempts to get the image name from the name of the method itself by
   * speculatively appending various image-like file extensions in a prioritized
   * order. The first image found, if any, is used.
   *
   * @param logger if no matching image resource is found, an explanatory
   *          message will be logged
   * @param method the method whose name is being examined for matching image
   *          resources
   * @return a resource name that is suitable to be passed into
   *         <code>ClassLoader.getResource()</code>; never returns
   *         <code>null</code>
   * @throws UnableToCompleteException thrown when no image can be found based
   *           on the method name
   */
  private String getImageNameFromMethodName(TreeLogger logger,
      JMethodOracle method) throws UnableToCompleteException {
    String pkgName = method.getPackageName();
    String pkgPrefix = pkgName.replace('.', '/');
    if (pkgPrefix.length() > 0) {
      pkgPrefix += "/";
    }
    String methodName = method.getName();
    String pkgAndMethodName = pkgPrefix + methodName;
    List<String> testImgNames = new ArrayList<String>();
    for (int i = 0; i < IMAGE_FILE_EXTENSIONS.length; i++) {
      String testImgName = pkgAndMethodName + '.' + IMAGE_FILE_EXTENSIONS[i];
      if (resLocator.isResourcePresent(testImgName)) {
        return testImgName;
      }
      testImgNames.add(testImgName);
    }

    TreeLogger branch = logger.branch(TreeLogger.ERROR,
        MSG_NO_FILE_BASED_ON_METHOD_NAME, null);
    for (String testImgName : testImgNames) {
      branch.log(TreeLogger.ERROR, testImgName, null);
    }

    throw new UnableToCompleteException();
  }

  private JClassType getValidUserType(TreeLogger logger, String typeName,
      TypeOracle typeOracle) throws UnableToCompleteException {
    try {
      // Get the type that the user is introducing.
      JClassType userType = typeOracle.getType(typeName);

      // Get the type this generator is designed to support.
      JClassType markerType = typeOracle.findType(IMAGEBUNDLE_QNAME);

      // Ensure it's an interface.
      if (userType.isInterface() == null) {
        logger.log(TreeLogger.ERROR, userType.getQualifiedSourceName()
            + " must be an interface", null);
        throw new UnableToCompleteException();
      }

      // Ensure proper derivation.
      if (!userType.isAssignableTo(markerType)) {
        logger.log(TreeLogger.ERROR, userType.getQualifiedSourceName()
            + " must be assignable to " + markerType.getQualifiedSourceName(),
            null);
        throw new UnableToCompleteException();
      }

      return userType;

    } catch (NotFoundException e) {
      logger.log(TreeLogger.ERROR, "Unable to find required type(s)", e);
      throw new UnableToCompleteException();
    }
  }

  /**
   * Attempts to get the image name (verbatim) from an annotation.
   *
   * @return the string specified in in the {@link ImageBundle.Resource}
   *         annotation, or <code>null</code>
   */
  @SuppressWarnings("deprecation")
  private String tryGetImageNameFromAnnotation(JMethodOracle method) {
    ImageBundle.Resource imgResAnn = method.getAnnotation(ImageBundle.Resource.class);
    String imgName = null;
    if (imgResAnn != null) {
      imgName = imgResAnn.value();
    }
    return imgName;
  }

  /**
   * Attempts to get the image name from an annotation.
   *
   * @param logger if an annotation is found but the specified resource isn't
   *          available, an error is logged
   * @param method the image bundle method whose associated image resource is
   *          being sought
   * @return a resource name that is suitable to be passed into
   *         <code>ClassLoader.getResource()</code>, or <code>null</code>
   *         if metadata wasn't provided
   * @throws UnableToCompleteException thrown when metadata is provided but the
   *           resource cannot be found
   */
  private String tryGetImageNameFromMetaData(TreeLogger logger,
      JMethodOracle method) throws UnableToCompleteException {
    String imgFileName = tryGetImageNameFromAnnotation(method);
    if (imgFileName == null) {
      // Exit early because neither an annotation nor javadoc was found.
      return null;
    }

    // If the name has no slashes (that is, it isn't a fully-qualified resource
    // name), then prepend the enclosing package name automatically, being
    // careful about the default package.
    if (imgFileName.indexOf("/") == -1) {
      String pkgName = method.getPackageName();
      if (!"".equals(pkgName)) {
        imgFileName = pkgName.replace('.', '/') + "/" + imgFileName;
      }
    }

    if (!resLocator.isResourcePresent(imgFileName)) {
      // Not found.
      logger.log(TreeLogger.ERROR, msgCannotFindImageFromMetaData(imgFileName),
          null);
      throw new UnableToCompleteException();
    }

    // Success.
    return imgFileName;
  }
}
