/*
 * $Id: ImageSaver.java 8312 2012-08-31 21:30:24Z uckelman $
 *
 * Copyright (c) 2000-2010 by Rodney Kinney, Joel Uckelman
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Library General Public
 * License (LGPL) as published by the Free Software Foundation.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Library General Public License for more details.
 *
 * You should have received a copy of the GNU Library General Public
 * License along with this library; if not, copies are available 
 * at http://www.opensource.org.
 */
package VASSAL.build.module.map;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.MediaTracker;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;

import javax.imageio.ImageIO;
import javax.imageio.ImageWriter;
import javax.imageio.event.IIOWriteProgressListener;
import javax.imageio.stream.ImageOutputStream;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;

import org.jdesktop.swingworker.SwingWorker;

import VASSAL.build.AbstractConfigurable;
import VASSAL.build.AutoConfigurable;
import VASSAL.build.Buildable;
import VASSAL.build.GameModule;
import VASSAL.build.module.Map;
import VASSAL.build.module.documentation.HelpFile;
import VASSAL.configure.ColorConfigurer;
import VASSAL.configure.Configurer;
import VASSAL.configure.ConfigurerFactory;
import VASSAL.configure.IconConfigurer;
import VASSAL.tools.ErrorDialog;
import VASSAL.tools.LaunchButton;
import VASSAL.tools.ProgressDialog;
import VASSAL.tools.WriteErrorDialog;
import VASSAL.tools.filechooser.FileChooser;
import VASSAL.tools.io.IOUtils;
import VASSAL.tools.logging.Logger;


// FIXME: Replace this in 3.2 with tiling code.

/**
 * This allows the user to capture a snapshot of the entire map into
 * a PNG file.
 */
public class ImageSaver extends AbstractConfigurable {
  protected LaunchButton launch;
  protected Map map;
  protected boolean promptToSplit = false;
  protected static final String DEFAULT_ICON = "/images/camera.gif";

  protected static ProgressDialog dialog;

  public ImageSaver() {
    final ActionListener al = new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        writeMapAsImage();
      }
    };
  
    launch =
      new LaunchButton(null, TOOLTIP, BUTTON_TEXT, HOTKEY, ICON_NAME, al);

    // Set defaults for backward compatibility
    launch.setAttribute(TOOLTIP, "Save Map as PNG image");
    launch.setAttribute(BUTTON_TEXT, "");
    launch.setAttribute(ICON_NAME, DEFAULT_ICON);
  }

  public ImageSaver(Map m) {
    super();
    map = m;
  }

  /**
   * Expects to be added to a {@link Map}. Adds a button to the map window
   * toolbar that initiates the capture
   */
  public void addTo(Buildable b) {
    map = (Map) b;
    map.getToolBar().add(launch);
  }

  public void removeFrom(Buildable b) {
    map = (Map) b;
    map.getToolBar().remove(launch);
    map.getToolBar().revalidate();
  }

  protected static final String HOTKEY = "hotkey";
  protected static final String BUTTON_TEXT = "buttonText";
  protected static final String TOOLTIP = "tooltip";
  protected static final String ICON_NAME = "icon";

  public String[] getAttributeNames() {
    return new String[] {
      BUTTON_TEXT,
      TOOLTIP,
      ICON_NAME,
      HOTKEY
    };
  }

  public String[] getAttributeDescriptions() {
    return new String[] {
      "Button Text:  ",
      "Tooltip Text:  ",
      "Button icon:  ",
      "Hotkey:  "
    };
  }

  public Class<?>[] getAttributeTypes() {
    return new Class<?>[] {
      String.class,
      String.class,
      IconConfig.class,
      KeyStroke.class
    };
  }

  public static class IconConfig implements ConfigurerFactory {
    public Configurer getConfigurer(AutoConfigurable c, String key, String name) {
      return new IconConfigurer(key, name, DEFAULT_ICON);
    }
  }

  public void setAttribute(String key, Object value) {
    launch.setAttribute(key, value);
  }

  public String getAttributeValueString(String key) {
    return launch.getAttributeValueString(key);
  }

  /**
   * Write a PNG-encoded snapshot of the map.
   */
  public void writeMapAsImage() {
    // prompt user for image filename
    final FileChooser fc = GameModule.getGameModule().getFileChooser();
    fc.setSelectedFile(new File(fc.getCurrentDirectory(),
      GameModule.getGameModule().getLocalizedGameName() + "Map.png"));

    final Frame frame =
      (Frame) SwingUtilities.getAncestorOfClass(Frame.class, map.getView());

    if (fc.showSaveDialog(frame) != FileChooser.APPROVE_OPTION) return; 
   
    final File file = fc.getSelectedFile();

    dialog = new ProgressDialog(frame, "Saving Map Image",
                                       "Saving map image...");

    // force the dialog to be a reasonable width
    // FIXME: this is not really a good way to do this---should do
    // something with the minimum size or font metrics
    final int l = "Saving map image as ".length() + file.getName().length() + 6;
    final StringBuilder b = new StringBuilder();
    for (int i = 0; i < l; i++) b.append("N"); 
    dialog.setLabel(b.toString());

    dialog.pack();
    dialog.setLabel("Saving map image as ");

    dialog.setIndeterminate(true);
    dialog.setLocationRelativeTo(frame);

    // get the dimensions of the image to write 
    final Dimension s = map.mapSize();
    
    if (s.width == 0) s.width = 1;
    if (s.height == 0) s.height = 1;

    int w = (int) Math.round(s.width * map.getZoom());
    int h = (int) Math.round(s.height * map.getZoom());
    
    // ensure that the resulting image is at least 1x1
    if (w < 1 || h < 1) {
      if (s.width < s.height) {
        w = 1;
        h = s.height/s.width;
      }
      else {
        h = 1;
        w = s.width/s.height;
      }
    }

    writeMapRectAsImage(file, 0, 0, w, h);

    dialog.setVisible(true);
  }

  /**
   * Helper method for writing images.
   *
   * @param file the file to write
   * @param x the left edge of the map area to write
   * @param y the top edge of the map area to write
   * @param w the width of the map area to write
   * @param h the height of the map area to write
   */
  protected void writeMapRectAsImage(File file, int x, int y, int w, int h) {
    final SnapshotTask task = new SnapshotTask(file, x, y, w, h);

    task.addPropertyChangeListener(new PropertyChangeListener() {
      public void propertyChange(PropertyChangeEvent e) {
        if ("progress".equals(e.getPropertyName())) {
          dialog.setProgress((Integer) e.getNewValue());
        }
        else if ("state".equals(e.getPropertyName())) {
          if ((SwingWorker.StateValue) e.getNewValue() ==
              SwingWorker.StateValue.DONE) {
            // close the dialog on cancellation or completion
            dialog.setVisible(false);
            dialog.dispose();
          }
        }
      }
    });

    dialog.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        task.cancel(true);
      }
    });

    task.execute();
  }

  private class SnapshotTask extends SwingWorker<Void,Void> {
    private int tiles;
    private int tilesDone = 0;

    private final File file;
    @SuppressWarnings("unused")
    private final int x;
    @SuppressWarnings("unused")
    private final int y;
    private final int w;
    private final int h;

    private final Color bg = ColorConfigurer.stringToColor(
      map.getAttributeValueString(Map.BACKGROUND_COLOR));

    private final List<File> files = new ArrayList<File>();

// FIXME: SnapshotTask ignores x,y!
    public SnapshotTask(File file, int x, int y, int w, int h) {
      this.file = file;
      this.x = x;
      this.y = y;
      this.w = w;
      this.h = h;
    }

    private void writeImage(final File f, BufferedImage img, Rectangle r)
                                                          throws IOException {

      files.add(f);

      // make sure that we can write the file before proceeding
      if (f.exists()) {
        if (!f.canWrite()) {
          throw new IOException(
            "Cannot write to the file \"" + f.getAbsolutePath() + "\""
          );
        }
      }
      else {
        final File p = f.getParentFile();
        if (p.isDirectory() && !p.canWrite()) {
          throw new IOException(
            "Cannot write to the directory \"" + p.getAbsolutePath() + "\""
          );
        }
      }

      // update the dialog on the EDT
      SwingUtilities.invokeLater(new Runnable() {
        public void run() {
          dialog.setLabel("Saving map image as " + f.getName() + ":");
          dialog.setIndeterminate(true);
        }
      });

      // FIXME: do something to estimate how long painting will take
      final Graphics2D g = img.createGraphics();

      final Color oc = g.getColor(); 
      g.setColor(bg);
      g.fillRect(0, 0, img.getWidth(), img.getHeight());
      g.setColor(oc);

      g.translate(-r.x, -r.y);
      map.paintRegion(g, r, null);
      g.dispose();

      // update the dialog on the EDT
      SwingUtilities.invokeLater(new Runnable() {
        public void run() {
          dialog.setIndeterminate(false);
        }
      });

      final ImageWriter iw = ImageIO.getImageWritersByFormatName("png").next();
      iw.addIIOWriteProgressListener(new IIOWriteProgressListener() {
        public void imageComplete(ImageWriter source) { }
      
        public void imageProgress(ImageWriter source, float percentageDone) {
          setProgress(Math.round((100*tilesDone + percentageDone)/tiles));
        }

        public void imageStarted(ImageWriter source, int imageIndex) { }

        public void thumbnailComplete(ImageWriter source) { }
  
        public void thumbnailProgress(ImageWriter source,
                                      float percentageDone) { }

        public void thumbnailStarted(ImageWriter source,
                                     int imageIndex, int thumbnailIndex) { }
  
        public void writeAborted(ImageWriter source) { }
      });
      
      ImageOutputStream os = null;
      try {
        os = ImageIO.createImageOutputStream(f);

        if (os == null) {
          throw new IOException("Failed to write file " + f.getAbsolutePath());
        }

        iw.setOutput(os);
        iw.write(img);
        os.close();
      }
      finally {
        iw.dispose();
        IOUtils.closeQuietly(os);
      }
    }

    @Override
    public Void doInBackground() throws IOException {
      setProgress(0);

      int tw = w;
      int th = h;
      BufferedImage img = null;

      // ensure that the size of the image data array (4 bytes per pixel)
      // does not exceed the maximum array size, 2^31-1 elements;
      // otherwise we'll overflow an int and have a negavive array size
      while ((long)tw * (long)th > Integer.MAX_VALUE/4) {
        if (tw > th) {
          tw = (int) Math.ceil(tw/2.0);
        }
        else {
          th = (int) Math.ceil(th/2.0);
        }
      }

      // find a size of BufferedImage we can allocate successfully
      while (img == null) {
        try {
          img = new BufferedImage(tw, th, BufferedImage.TYPE_INT_ARGB);
        }
        catch (OutOfMemoryError e) {
          if (tw > th) {
            tw = (int) Math.ceil(tw/2.0);
          }
          else {
            th = (int) Math.ceil(th/2.0);
          }
        }
      }

      if (tw == w && th == h) {
        // write the whole map as one image
        tiles = 1;
        writeImage(file, img, new Rectangle(0, 0, w, h));
      }
      else {
        // get the base name of the files to write 
        final String base;
        final String suffix; 
        final String s = file.getName();
        if (s.endsWith(".png")) {
          base = s.substring(0, s.lastIndexOf("."));
          suffix = ".png";
        }
        else {
          base = s;
          suffix = "";
        }

        // calculate total tiles 
        final int tcols = (int) Math.ceil((double) w / tw);
        final int trows = (int) Math.ceil((double) h / th);

        tiles = tcols * trows;

        // tile across the map with images of size tw by th.
        for (int tx = 0; tx < tcols; ++tx) {
          for (int ty = 0; ty < trows; ++ty) {
            final File f = new File(file.getParent(),
              base + "." + tx + "." + ty + suffix);
  
            final Rectangle r = new Rectangle(
              tw*tx,
              th*ty,
              Math.min(tw, w - tw*tx),
              Math.min(th, h - th*ty)
            );
  
            writeImage(f, img, r);
            ++tilesDone;
          }
        }
      }

      return null;
    }

    @Override
    protected void done() {
      try {
        get();
      }
      catch (CancellationException e) {
        // on cancellation, remove all files we created
        for (File f : files) f.delete();
      }
      catch (InterruptedException e) {
        ErrorDialog.bug(e);
      }
      catch (ExecutionException e) {
        final Throwable c = e.getCause();
        if (c instanceof IOException) {
          WriteErrorDialog.error(e, (IOException) c, files.get(files.size()-1));
        }
        else {
          ErrorDialog.bug(e);
        }
      }
    } 
  }

  /**
   * Write a PNG-encoded snapshot of the map to the given OutputStreams,
   * dividing the map into vertical sections, one per stream
   *
   * @deprecated
   */
  @Deprecated
  public void writeImage(OutputStream[] out) throws IOException {
    Dimension buffer = map.getEdgeBuffer();
    int totalWidth =
      (int) ((map.mapSize().width - 2 * buffer.width) * map.getZoom());
    int totalHeight =
      (int) ((map.mapSize().height - 2 * buffer.height) * map.getZoom());
    for (int i = 0; i < out.length; ++i) {
      int height = totalHeight / out.length;
      if (i == out.length - 1) {
        height = totalHeight - height * (out.length - 1);
      }

      Image output = map.getView().createImage(totalWidth, height);
      Graphics2D gg = (Graphics2D) output.getGraphics();
      
      map.paintRegion(gg, new Rectangle(
        -(int) (map.getZoom() * buffer.width),
        -(int) (map.getZoom() * buffer.height) + height * i,
        totalWidth, totalHeight), null);
      gg.dispose();
      try {
        MediaTracker t = new MediaTracker(map.getView());
        t.addImage(output, 0);
        t.waitForID(0);
      }
      catch (Exception e) {
        Logger.log(e);
      }

      try {
        if (output instanceof RenderedImage) {
          ImageIO.write((RenderedImage) output, "png", out[i]);
        }
        else {
          throw new IOException("Bad image type");
        }
      }
      finally {
        try {
          out[i].close();
        }
        catch (IOException e) {
          Logger.log(e);
        }
      }
    }
  }

  public HelpFile getHelpFile() {
    return HelpFile.getReferenceManualPage("Map.htm", "ImageCapture");
  }

  public static String getConfigureTypeName() {
    return "Image Capture Tool";
  }

  public Class<?>[] getAllowableConfigureComponents() {
    return new Class<?>[0];
  }
}
