/*
 * 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.GeneratorContext;
import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.UnableToCompleteException;
import com.google.gwt.dev.util.log.perf.SimpleEvent;
import com.google.gwt.thirdparty.guava.common.hash.Hashing;
import com.google.gwt.thirdparty.guava.common.hash.HashingOutputStream;

import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import javax.imageio.ImageIO;

/**
 * Accumulates state for the bundled image.
 */
class ImageBundleBuilder {
  /**
   * The rectangle at which the original image is placed into the composite
   * image.
   */
  public static class ImageRect implements HasRect {

    private final String name;
    private final int height, width;
    private final BufferedImage image;
    private int left, top;

    private boolean hasBeenPositioned;

    public ImageRect(String name, BufferedImage image) {
      this.name = name;
      this.image = image;
      this.width = image.getWidth();
      this.height = image.getHeight();
    }

    public int getHeight() {
      return height;
    }

    public int getLeft() {
      return left;
    }

    public String getName() {
      return name;
    }

    public int getTop() {
      return top;
    }

    public int getWidth() {
      return width;
    }

    public boolean hasBeenPositioned() {
      return hasBeenPositioned;
    }

    public void setPosition(int left, int top) {
      hasBeenPositioned = true;
      this.left = left;
      this.top = top;
    }
  }

  /**
   * A mockable interface to test the image arrangement algorithms.
   */
  interface HasRect {

    String getName();

    int getHeight();

    int getLeft();

    int getTop();

    int getWidth();

    boolean hasBeenPositioned();

    void setPosition(int left, int top);
  }

  /**
   * Used to return the size of the resulting image from the method
   * {@link ImageBundleBuilder#arrangeImages()}.
   */
  private static class Size {
    private final int width, height;

    Size(int width, int height) {
      this.width = width;
      this.height = height;
    }
  }

  private static final Comparator<HasRect> decreasingHeightComparator = new Comparator<HasRect>() {
    public int compare(HasRect a, HasRect b) {
      final int c = b.getHeight() - a.getHeight();
      // If we encounter equal heights, use the name to keep things
      // deterministic.
      return (c != 0) ? c : b.getName().compareTo(a.getName());
    }
  };

  private static final Comparator<HasRect> decreasingWidthComparator = new Comparator<HasRect>() {
    public int compare(HasRect a, HasRect b) {
      final int c = b.getWidth() - a.getWidth();
      // If we encounter equal heights, use the name to keep things
      // deterministic.
      return (c != 0) ? c : b.getName().compareTo(a.getName());
    }
  };

  /*
   * Only PNG is supported right now. In the future, we may be able to infer the
   * best output type, and get rid of this constant.
   */
  private static final String BUNDLE_FILE_TYPE = "png";

  /**
   * Arranges the images to try to decrease the overall area of the resulting
   * bundle. This uses a strategy that is basically Next-Fit Decreasing Height
   * Decreasing Width (NFDHDW). The rectangles to be packed are sorted in
   * decreasing order by height. The tallest rectangle is placed at the far
   * left. We attempt to stack the remaining rectangles on top of one another to
   * construct as many columns as necessary. After finishing each column, we
   * also attempt to do some horizontal packing to fill up the space left due to
   * widths of rectangles differing in the column.
   */
  static Size arrangeImages(Collection<? extends HasRect> rects) {
    if (rects.size() == 0) {
      return new Size(0, 0);
    }

    // Create a list of ImageRects ordered by decreasing height used for
    // constructing columns.
    final ArrayList<HasRect> rectsOrderedByHeight = new ArrayList<HasRect>(
        rects);
    Collections.sort(rectsOrderedByHeight, decreasingHeightComparator);

    // Create a list of ImageRects ordered by decreasing width used for packing
    // individual columns.
    final ArrayList<HasRect> rectsOrderedByWidth = new ArrayList<HasRect>(rects);
    Collections.sort(rectsOrderedByWidth, decreasingWidthComparator);

    // Place the first, tallest image as the first column.
    final HasRect first = rectsOrderedByHeight.get(0);
    first.setPosition(0, 0);

    // Setup state for laying things cumulatively.
    int curX = first.getWidth();
    final int colH = first.getHeight();

    for (int i = 1, n = rectsOrderedByHeight.size(); i < n; i++) {
      // If this ImageRect has been positioned already, move on.
      if (rectsOrderedByHeight.get(i).hasBeenPositioned()) {
        continue;
      }

      int colW = 0;
      int curY = 0;

      final ArrayList<HasRect> rectsInColumn = new ArrayList<HasRect>();
      for (int j = i; j < n; j++) {
        final HasRect current = rectsOrderedByHeight.get(j);
        // Look for rects that have not been positioned with a small enough
        // height to go in this column.
        if (!current.hasBeenPositioned()
            && (curY + current.getHeight()) <= colH) {

          // Set the horizontal position here, the top field will be set in
          // arrangeColumn after we've collected a full set of ImageRects.
          current.setPosition(curX, 0);
          colW = Math.max(colW, current.getWidth());
          curY += current.getHeight();

          // Keep the ImageRects in this column in decreasing order by width.
          final int pos = Collections.binarySearch(rectsInColumn, current,
              decreasingWidthComparator);
          assert pos < 0;
          rectsInColumn.add(-1 - pos, current);
        }
      }

      // Having selected a set of ImageRects that fill out this column vertical,
      // now we'll scan the remaining ImageRects to try to fit some in the
      // horizontal gaps.
      if (!rectsInColumn.isEmpty()) {
        arrangeColumn(rectsInColumn, rectsOrderedByWidth);
      }

      // We're done with that column, so move the horizontal accumulator by the
      // width of the column we just finished.
      curX += colW;
    }

    return new Size(curX, colH);
  }

  /**
   * Companion method to {@link #arrangeImages()}. This method does a best
   * effort horizontal packing of a column after it was packed vertically. This
   * is the Decreasing Width part of Next-Fit Decreasing Height Decreasing
   * Width. The basic strategy is to sort the remaining rectangles by decreasing
   * width and try to fit them to the left of each of the rectangles we've
   * already picked for this column.
   *
   * @param rectsInColumn the ImageRects that were already selected for this
   *          column
   * @param remainingRectsOrderedByWidth the sub list of ImageRects that may not
   *          have been positioned yet
   */
  private static void arrangeColumn(List<HasRect> rectsInColumn,
      List<HasRect> remainingRectsOrderedByWidth) {
    final HasRect first = rectsInColumn.get(0);

    final int columnWidth = first.getWidth();
    int curY = first.getHeight();

    // Skip this first ImageRect because it is guaranteed to consume the full
    // width of the column.
    for (int i = 1, m = rectsInColumn.size(); i < m; i++) {
      final HasRect r = rectsInColumn.get(i);
      // The ImageRect was previously positioned horizontally, now set the top
      // field.
      r.setPosition(r.getLeft(), curY);
      int curX = r.getWidth();

      // Search for ImageRects that are shorter than the left most ImageRect and
      // narrow enough to fit in the column.
      for (int j = 0, n = remainingRectsOrderedByWidth.size(); j < n; j++) {
        final HasRect current = remainingRectsOrderedByWidth.get(j);
        if (!current.hasBeenPositioned()
            && (curX + current.getWidth()) <= columnWidth
            && (current.getHeight() <= r.getHeight())) {
          current.setPosition(r.getLeft() + curX, r.getTop());
          curX += current.getWidth();
        }
      }

      // Update the vertical accumulator so we'll know where to place the next
      // ImageRect.
      curY += r.getHeight();
    }
  }

  private final Map<String, ImageRect> imageNameToImageRectMap = new HashMap<String, ImageRect>();

  /**
   * Assimilates the image associated with a particular image method into the
   * master composite. If the method names an image that has already been
   * assimilated, the existing image rectangle is reused.
   *
   * @param logger a hierarchical logger which logs to the hosted console
   * @param imageName the name of an image that can be found on the classpath
   * @throws UnableToCompleteException if the image with name
   *           <code>imageName</code> cannot be added to the master composite
   *           image
   */
  public void assimilate(TreeLogger logger, String imageName)
      throws UnableToCompleteException {

    /*
     * Decide whether or not we need to add to the composite image. Either way,
     * we associated it with the rectangle of the specified image as it exists
     * within the composite image. Note that the coordinates of the rectangle
     * aren't computed until the composite is written.
     */
    ImageRect rect = getMapping(imageName);
    if (rect == null) {
      // Assimilate the image into the composite.
      rect = addImage(logger, imageName);

      // Map the URL to its image so that even if the same URL is used more than
      // once, we only include the referenced image once in the bundled image.
      putMapping(imageName, rect);
    }
  }

  public ImageRect getMapping(String imageName) {
    return imageNameToImageRectMap.get(imageName);
  }

  public String writeBundledImage(TreeLogger logger, GeneratorContext context)
      throws UnableToCompleteException {

    // Create the bundled image from the constituent images.
    BufferedImage bundledImage = drawBundledImage();

    ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
    HashingOutputStream hashingOutputStream = new HashingOutputStream(Hashing.murmur3_128(),
        byteOutputStream);
    try {
      boolean writerAvailable = ImageIO.write(bundledImage, BUNDLE_FILE_TYPE, hashingOutputStream);

      if (!writerAvailable) {
        logger.log(TreeLogger.ERROR, "No " + BUNDLE_FILE_TYPE
            + " writer available");
        throw new UnableToCompleteException();
      }
    } catch (IOException e) {
      logger.log(TreeLogger.ERROR,
          "An error occurred while trying to write the image bundle.", e);
      throw new UnableToCompleteException();
    }

    // Compute the file name. The strong name is generated from the bytes of
    // the bundled image. The '.cache' part indicates that it can be
    // permanently cached.
    String strongName = hashingOutputStream.hash().toString().toUpperCase(Locale.ROOT);
    String bundleFileName = strongName + ".cache." + BUNDLE_FILE_TYPE;

    // Try and write the file to disk. If a file with bundleFileName already
    // exists, then the file will not be written.
    OutputStream outStream = context.tryCreateResource(logger, bundleFileName);

    if (outStream != null) {
      try {
        // Write the image bytes from the byte array to the pending stream.
        outStream.write(byteOutputStream.toByteArray());

        // Commit the stream.
        context.commitResource(logger, outStream);

      } catch (IOException e) {
        logger.log(TreeLogger.ERROR, "Failed while writing", e);
        throw new UnableToCompleteException();
      }
    } else {
      logger.log(TreeLogger.TRACE,
          "Generated image bundle file already exists; no need to rewrite it.",
          null);
    }

    return bundleFileName;
  }

  private ImageRect addImage(TreeLogger logger, String imageName)
      throws UnableToCompleteException {

    logger = logger.branch(TreeLogger.TRACE,
        "Adding image '" + imageName + "'", null);

    // Fetch the image.
    try {
      // Could turn this lookup logic into an externally-supplied policy for
      // increased generality.
      URL imageUrl = getClass().getClassLoader().getResource(imageName);
      if (imageUrl == null) {
        // This should never happen, because this check is done right after
        // the image name is retrieved from the metadata or the method name.
        // If there is a failure in obtaining the resource, it will happen
        // before this point.
        logger.log(TreeLogger.ERROR,
            "Resource not found on classpath (is the name specified as "
                + "ClassLoader.getResource() would expect?)", null);
        throw new UnableToCompleteException();
      }

      BufferedImage image;
      // Load the image
      try {
        image = ImageIO.read(imageUrl);
      } catch (IllegalArgumentException iex) {
        if (imageName.toLowerCase(Locale.ROOT).endsWith("png")
            && iex.getMessage() != null
            && iex.getStackTrace()[0].getClassName().equals(
                "javax.imageio.ImageTypeSpecifier$Indexed")) {
          logger.log(
              TreeLogger.ERROR,
              "Unable to read image. The image may not be in valid PNG format. "
                  + "This problem may also be due to a bug in versions of the "
                  + "JRE prior to 1.6. See "
                  + "http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=5098176 "
                  + "for more information. If this bug is the cause of the "
                  + "error, try resaving the image using a different image "
                  + "program, or upgrade to a newer JRE.", null);
          throw new UnableToCompleteException();
        } else {
          throw iex;
        }
      }

      if (image == null) {
        logger.log(TreeLogger.ERROR, "Unrecognized image file format", null);
        throw new UnableToCompleteException();
      }

      return new ImageRect(imageName, image);

    } catch (IOException e) {
      logger.log(TreeLogger.ERROR, "Unable to read image resource", null);
      throw new UnableToCompleteException();
    }
  }

  /**
   * This method creates the bundled image through the composition of the other
   * images.
   *
   * In this particular implementation, we use NFDHDW (see
   * {@link #arrangeImages()}) to get an approximate optimal image packing.
   *
   * The most important aspect of drawing the bundled image is that it be drawn
   * in a deterministic way. The drawing of the image should not rely on
   * implementation details of the Generator system which may be subject to
   * change.
   */
  private BufferedImage drawBundledImage() {

    // There is no need to impose any order here, because arrangeImages
    // will position the ImageRects in a deterministic fashion, even though
    // we might paint them in a non-deterministic order.
    Collection<ImageRect> imageRects = imageNameToImageRectMap.values();

    // Arrange images and determine the size of the resulting bundle.
    final Size size = arrangeImages(imageRects);

    // Create the bundled image.
    BufferedImage bundledImage = new BufferedImage(size.width, size.height,
        BufferedImage.TYPE_INT_ARGB_PRE);
    final Graphics2D g2d;
    try (SimpleEvent ignore = new SimpleEvent("Graphics2d")) {
      g2d = bundledImage.createGraphics();
    }

    for (ImageRect imageRect : imageRects) {

      // We do not need to pass in an ImageObserver, because we are working
      // with BufferedImages. ImageObservers only need to be used when
      // the image to be drawn is being loaded asynchronously. See
      // http://java.sun.com/docs/books/tutorial/2d/images/drawimage.html
      // for more information.
      g2d.drawImage(imageRect.image, imageRect.left, imageRect.top, null);
    }
    g2d.dispose();

    return bundledImage;
  }

  private void putMapping(String imageName, ImageRect rect) {
    imageNameToImageRectMap.put(imageName, rect);
  }
}
