/*
 * Copyright (c) 2008 by Michael Kiefte
 *
 * 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.tools.imports.adc2;

import java.awt.AlphaComposite;
import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.image.BandCombineOp;
import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.HashMap;

import javax.imageio.ImageIO;

import VASSAL.build.GameModule;
import VASSAL.tools.filechooser.BMPFileFilter;
import VASSAL.tools.imports.FileFormatException;
import VASSAL.tools.imports.ImportAction;
import VASSAL.tools.imports.Importer;
import VASSAL.tools.io.IOUtils;

/**
 * ADC2 game piece and terrain symbols.
 * 
 * @author Michael Kiefte
 *
 */
public class SymbolSet extends Importer{

  private static final int OLD_SYMBOL_SET_FORMAT = 0xFD;

  /**
   * Shape of the terrain elements.
   */
  enum Shape {  // an enum is overkill here...
    SQUARE, HEX
  }

  /**
   * Contains all of the information for a single game piece or terrain icon
   */
  class SymbolData {
    
    /**
     * Shared bitmap of all symbols in either the terrain or game piece set.
     * Cannot be static as there are three different possible shared images
     * corresponding to game pieces, terrain features, and bitmasks.
     */ 
    private final BufferedImage bitmap;

    /** 
     * Actual name of image file for this symbol in archive. May not be the same as the name 
     * provided in the configuration file if duplicates exist.
     */
    private String fileName;

    /** 
     * Actual image which is lazily generated on request.
     */
    private BufferedImage img;

    /**
     * Prevents infinite loops when applying masks to symbol images.
     */ 
    private final boolean isMask;

    /**
     * Base 1 index into SymbolData array of masks. 0 means no mask.
     */
    private int maskIndex;

    /**
     * Actual name given in the configuration file--not the archive file name
     * as duplicates are permitted in ADC2.
     */ 
    private String name;

    /**
     * Rectangle in shared bitmap <code>img</code>.
     */
    private Rectangle rect;

    /**
     * <code>Boolean.TRUE</code> if the image is completely transparent (invisible)
     * and <code>null</code> if it has not yet been checked. 
     */
    private Boolean transparent;
    
    SymbolData(BufferedImage bitmap, boolean isMask) {
      this.bitmap = bitmap;
      this.isMask = isMask;
    }

    /**
     * Get the mask associated with this symbol. Returns <code>null</code> it this
     * is a mask, if it is from an older version file, or if there is no mask.
     */
    protected SymbolData getMask() {
      if (ignoreMask || isMask)
        return null;
      // mask index is base 1
      else if (maskIndex > 0 && maskIndex <= maskData.length)
        return maskData[maskIndex - 1];
      else
        return null;
    }

    /**
     * Read symbol data from configuration file.
     * 
     * @return   <code>this</code>, so that methods may be chained in series 
     *           following <code>read</code>.
     */
    protected SymbolData read(DataInputStream in) throws IOException {
      name = readNullTerminatedString(in);

      // Have to read in all the zoom level sizes for the first one
      // so that zooming in VASSAL can approximate the zoom behaviour of the
      // imported module.
      boolean readAllZoomLevels = false;
      if (mapBoardSymbolSize == null) {
        mapBoardSymbolSize = new int[3];
        readAllZoomLevels = true;
      }
      
      // Alternate indexing style (to be implemented):
      // if (header == -3)
      //    pict.maskIndex = in.readUnsignedByte();
      // else
      maskIndex = header == OLD_SYMBOL_SET_FORMAT ? in.readUnsignedByte() : ADC2Utils.readBase250Word(in);
      for (int i = 0; i < 3; ++i) {
        int x1 = in.readInt();
        int y1 = in.readInt();
        int x2 = in.readInt();
        int y2 = in.readInt();
        int width = x2 - x1 + 1;
        int height = y2 - y1 + 1;
        if (readAllZoomLevels)
          mapBoardSymbolSize[i] = height; // or width--they should be square
        if (i == zoomLevel)
          rect = new Rectangle(x1, y1, width, height);
      }
      return this;
    }

    /**
     * Returns the archive file name corresponding to the image for this
     * symbol. If called, will actually write the image to the archive in
     * order to get the file name itself.
     * 
     * @throws IOException if unable to read the image files for this symbol.
     */
    String getFileName() throws IOException {
      if (fileName == null)
        writeToArchive();
      return fileName;
    }

    /**
     * Returns true if all alpha values are zero.
     */
    boolean isTransparent() {
      if (transparent == null) {
        BufferedImage image = getImage();
        transparent  = Boolean.TRUE;
        search:
          for (int i = 0; i < image.getWidth(); ++i) {
            for (int j = 0; j < image.getHeight(); ++j) {
              if (image.getRGB(i, j) != 0) {
                transparent = Boolean.FALSE;
                break search;
              }
            }
          }
        
      }
      return transparent.booleanValue();
    }
    
    /**
     * Get image corresponding to this symbol. Generates the image and applies
     * optional mask if not already done so.
     * @param rect2 width and height are taken from this for otherwise invalid masks
     */
    private BufferedImage getImage(Rectangle rect2) {
      if (img == null) {
        if ( isMask && (rect.width <= 0 || rect.height <= 0 
            || rect.width+rect.x > bitmap.getWidth() 
            || rect.height+rect.y > bitmap.getHeight() )) {
          // Images with invalid masks appear to be completely transparent.
          // This is a hassle generating new ones all the time, but there's nothing
          // to say that the real mask can't be different sizes at every call,
          // and anything else seems like overkill -- so this is an ugly kludge.
          // Hopefully, this crime against nature doesn't happen very often.
          return new BufferedImage(rect2.width, rect2.height, BufferedImage.TYPE_INT_ARGB);
        }
        img = bitmap.getSubimage(rect.x, rect.y, rect.width, rect.height);
        if (getMask() != null) {
          final BufferedImage bi = new BufferedImage(rect.width, rect.height, BufferedImage.TYPE_INT_ARGB);
          final Graphics2D g = bi.createGraphics();
          g.drawImage(img, null, 0, 0);
          g.setComposite(AlphaComposite.DstAtop);
          g.drawImage(getMask().getImage(rect), null, 0, 0);
          img = bi;
        }
      }
      return img;
    }
    
    BufferedImage getImage() {
      return getImage(rect);
    }

    /**
     * Write symbol image to archive and return archive file name. Will only
     * write to archive once.
     */
    protected void writeToArchive() throws IOException {
      // only gets written once. if filename is not null, then we've
      // already been written
      if (fileName == null) { // this condition is really just a failsafe check
        fileName = getUniqueImageFileName(name);
        final ByteArrayOutputStream out = new ByteArrayOutputStream();
        ImageIO.write(getImage(), "png", out);
        GameModule.getGameModule().getArchiveWriter().addImage(fileName, out.toByteArray());
      }
    }
  }

  // Permute negative of red band (doesn't matter which colour) to alpha band.
  // masks are originally black where alpha should be 1.0 and white where
  // alpha should be 0.0.
  private final static float[][] THREE_BAND_MATRIX = { 
      { 0.0f, 0.0f, 0.0f, 0.0f },
      { 0.0f, 0.0f, 0.0f, 0.0f }, 
      { 0.0f, 0.0f, 0.0f, 0.0f },
      { -1.0f, 0.0f, 0.0f, 255.0f }};
  
  private final static float[][] ONE_BAND_MATRIX = { 
      { 0.0f, 0.0f }, 
      { 0.0f, 0.0f },
      { 0.0f, 0.0f }, 
      { -255.0f, 255.0f }};

  /**
   * Convert a black-and-white bitmap to a mask image.
   */
  private static BufferedImage generateAlphaMask(BufferedImage img) {
    final BandCombineOp op;
    if (img.getSampleModel().getNumBands() == 1)
      op = new BandCombineOp(ONE_BAND_MATRIX, null);
    else
      op = new BandCombineOp(THREE_BAND_MATRIX, null);
    final BufferedImage bi = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_ARGB);
    op.filter(img.getRaster(), bi.getRaster());
    return bi;
  }

  /**
   * Unit symbols
   */
  private SymbolData[] gamePieceData;

  /**
   * Terrain symbols
   */
  private SymbolData[] mapBoardData;

  /**
   * Symbol size for all three zoom levels. Used to set zoom scale in VASSAL.
   */
  private int[] mapBoardSymbolSize;

  /** 
   * Mask symbols. Only used by SymbolData when requesting a mask index.
   * Doesn't get used at all in Version I sets (where <code>ignoreMask</code> is true).
   */
  private SymbolData[] maskData = null;

  /**
   * Hex or square.
   */
  private Shape symbolShape;

  /** 
   * Zoom level to import
   */
  private static final int zoomLevel = 2;

  /**
   * Ignore mask despite specified mask indeces.  True for older configuration files.
   */
  private boolean ignoreMask;

  private boolean isCardSet;

  private int header;

  BufferedImage underlay;

  /**
   * Read symbol images based on basename and suffix. Bitmap filenames are of the form
   * <tt>name + "-CN.bmp"</tt> where C is the specified suffix ('M' for masks; 'U' for
   * game pieces; 'T' for terrain symbols) and N is the base-1 zoom level.
   * 
   * @param filename base file name. Should be the same as the base file name of the
   *                 symbol set file.
   * @param suffix   single character suffix
   */
  BufferedImage loadSymbolImage(String filename, char suffix, boolean queryIfNotFound) throws IOException {
    String fn;
    if (suffix == '\0')
      fn = filename + (zoomLevel + 1) + ".bmp";
    else
      fn = filename + '-' + suffix + (zoomLevel + 1) + ".bmp";
    File f = action.getCaseInsensitiveFile(new File(fn), null, queryIfNotFound, new BMPFileFilter());
    if (f == null && queryIfNotFound) {
      throw new FileNotFoundException("Missing bitmap file: " + fn);
    }
    else if (f == null) {
      return null;
    }
    else {
      return ImageIO.read(f);
    }
  }
  
  BufferedImage loadSymbolImage(String filename, char suffix) throws IOException {
	  return loadSymbolImage(filename, suffix, true);
  }

  /**
   * Read dimensions from input file. The dimensions must occur in triplets in the
   * configuration file--each one corresponding to a zoom level. 
   * Only the third dimension, corresponding to zoom level 3, is returned.
   */
  Dimension readDimension(DataInputStream in) throws IOException {
    Dimension d = null;
    for (int i = 0; i < 3; ++i) {
      int width = in.readInt();
      int height = in.readInt();
      if (zoomLevel == i)
        d = new Dimension(width, height);
    }
    return d;
  }

  /**
   * Returns the <code>SymbolData</code> corresponding to the game piece at
   * the specified index.
   * 
   * @return      <code>null</code> if the index is out of bounds.
   */
  SymbolData getGamePiece(int index) {
    if (index >= 0 && index < gamePieceData.length)
      return gamePieceData[index];
    else
      return null;
  }
  
  /**
   * Returns the <code>SymbolData</code> corresponding to the terrain symbol at
   * the specified index.
   * 
   * @return      <code>null</code> if the index is out of bounds.
   */
  SymbolData getMapBoardSymbol(int index) {
    if (index >= 0 & index < mapBoardData.length)
      return mapBoardData[index];
    else
      return null;
  }

  public Dimension getMaxSize(Dimension max) {
    if (max == null)
      max = new Dimension(0,0);
    for (SymbolData piece : gamePieceData) {
      BufferedImage im = piece.getImage();
      if (im.getWidth() > max.width)
        max.width = im.getWidth();
      if (im.getHeight() > max.height)
        max.height = im.getHeight();
    }
    return max;
  }
  
  public Dimension getMaxSize() {
    return getMaxSize(null);
  }
  
  /**
   * @return The most frequently occuring dimension for game pieces in this module.
   */
  public Dimension getModalSize() {
    final HashMap<Dimension, Integer> histogram = new HashMap<Dimension, Integer>();
    for (SymbolData piece : gamePieceData) {
      BufferedImage im = piece.getImage();
      Dimension d = new Dimension(im.getWidth(), im.getHeight());
      Integer i = histogram.get(d);
      if (i == null)
        histogram.put(d, 1);
      else
        histogram.put(d, i+1);
    }
    int max = 0;
    final Dimension maxDim = new Dimension(0,0);
    for (Dimension d : histogram.keySet()) {
      int n = histogram.get(d);
      if (n > max) {
        max = n;          
        maxDim.height = d.height;
        maxDim.width = d.width;
      }
    }
    return maxDim;
  }

  /**
   * @return Map board symbol size corresponding to the current zoom level.
   */
  int getMapBoardSymbolSize() {
    return mapBoardSymbolSize[zoomLevel];
  }

  /**
   * @return Map board symbol size corresponding to the specified zoom level
   */
  double getZoomFactor(int zoomLevel) {
    return (double) mapBoardSymbolSize[zoomLevel] / (double) mapBoardSymbolSize[SymbolSet.zoomLevel];
  }

  /**
   * @return Hex or square?
   */
  Shape getMapBoardSymbolShape() {
    return symbolShape;
  }

  /**
   * Read a card set from the specified file.
   * @throws IOException 
   */
  void importCardSet(ImportAction a, File f) throws IOException {
    isCardSet = true;
    importFile(a, f);
  }
  
  /**
   * Read a symbol set from the specified file.
   */
  protected void load(File f) throws IOException {
    super.load(f);
    DataInputStream in = null;
    
    try {
      in = new DataInputStream(new BufferedInputStream(new FileInputStream(f)));

      // if header is 0xFD, then mask indeces are one-byte long. Otherwise, if
      // the header is anything else greater than 0xFA, then mask indeces are base-250
      // two-byte words. 
      header = in.readUnsignedByte();
      if (header < 0xFA)
        throw new FileFormatException("Invalid Symbol Set Header");

      // comletely overridden by the map file
      /* int orientation = */ in.readByte(); // 1=vertical; 2=horizontal; 3=grid

      int mapStyle = in.readByte(); // 0=grid; all other values=hex    
      switch (mapStyle) {
      case 1:
        symbolShape = Shape.HEX;
        break;
      default:
        symbolShape = Shape.SQUARE;  
      }

      int symSetVersion = in.readByte(); // 1=version 1
      switch (symSetVersion) {
      case 0:
        ignoreMask = false;
        break;
      default:
        ignoreMask = true;
      }
      if (isCardSet)
        ignoreMask = true;

      // bitmap dimensions are completely ignored
      int nMapBoardSymbols = ADC2Utils.readBase250Word(in);
      mapBoardData = new SymbolData[nMapBoardSymbols];
      /* terrainBitmapDims = */ readDimension(in);

      int nGamePieceSymbols = ADC2Utils.readBase250Word(in);
      gamePieceData = new SymbolData[nGamePieceSymbols];
      /* unitBitmapDims = */ readDimension(in);

      int nMasks = ADC2Utils.readBase250Word(in);
      /* maskBitmapDims = */ readDimension(in);
      maskData = new SymbolData[nMasks];

      String baseName = stripExtension(f.getPath());

      // load images
      BufferedImage mapBoardImages = null;
      if (!isCardSet)
        mapBoardImages = loadSymbolImage(baseName, 't');
      for (int i = 0; i < nMapBoardSymbols; ++i) {
        mapBoardData[i] = new SymbolData(mapBoardImages, false).read(in);
        // check for size consistency. Not sure what to do if they're not
        // all the same size or not square
        if (!isCardSet) {
          if (mapBoardData[i].rect.height != mapBoardData[0].rect.height
              || mapBoardData[i].rect.width != mapBoardData[0].rect.width)
            throw new FileFormatException("Map board image dimensions are inconsistent");
          if (mapBoardData[i].rect.width != mapBoardData[i].rect.height)
            throw new FileFormatException("Map board image dimensions are not square");
        }
      }

      BufferedImage gamePieceImages;
      if (isCardSet) {
        gamePieceImages = loadSymbolImage(baseName);
      }
      else {
        gamePieceImages = loadSymbolImage(baseName, 'u');
      }

      for (int i = 0; i < nGamePieceSymbols; ++i) {
        gamePieceData[i] = new SymbolData(gamePieceImages, false).read(in);
      }

      if (!ignoreMask) {
        BufferedImage maskImages = loadSymbolImage(baseName, 'm');
        // convert binary bitmap to RGBA alpha mask
        maskImages = generateAlphaMask(maskImages);

        for (int i = 0; i < nMasks; ++i)
          maskData[i] = new SymbolData(maskImages, true).read(in);
      }

      in.close();
      
      /* See if there is a single-image underlay for the map. */
      underlay = loadSymbolImage(baseName, 'z', false);
    }
    finally {
      IOUtils.closeQuietly(in);
    }
      
    readPermutationFile(f);
  }

  private BufferedImage loadSymbolImage(String string) throws IOException {
    return loadSymbolImage(string, '\0');
  }

  /**
   * Read an SDX file if one exists. This is a list of image indeces starting with terrain 
   * separated by newlines. Only piece images are actually permuted.
   * 
   * @param f - Set file.
   * @throws IOException
   */
  protected void readPermutationFile(File f) throws IOException {
    File sdx = new File(forceExtension(f.getPath(), "sdx"));
    sdx = action.getCaseInsensitiveFile(sdx, f, false, null);
    if (sdx != null) { // must reorder image indeces
      BufferedReader input = null;
      
      try {
        input = new BufferedReader(new FileReader(sdx));

        SymbolData[] pieces = new SymbolData[gamePieceData.length];
        System.arraycopy(gamePieceData, 0, pieces, 0, pieces.length);

        String line = null;      
        try {
          for (int i = 0; i < mapBoardData.length; ++i) {
            line = input.readLine();
          }
          for (int i = 0; i < pieces.length; ++i) {
            line = input.readLine();
            int idx = Integer.parseInt(line);
            pieces[i] = gamePieceData[idx-1];
          }
        }
        catch (EOFException e) {}
        catch (ArrayIndexOutOfBoundsException e) {
          throw new FileFormatException("SDX file has out-of-bounds index \"" + line + "\".");
        }
        catch (NumberFormatException e) {
          throw new FileFormatException("SDX file has invalid index \"" + line + "\".");
        }
        finally {
          gamePieceData = pieces;
        }
        
        input.close();
      }
      finally {
        IOUtils.closeQuietly(input);
      }
    }
  }

  /**
   * Write all of the game pieces to the archive.  Mainly for testing or if only
   * the symbol set is imported.
   */
  public void writeToArchive() throws IOException {
    for (SymbolData piece : gamePieceData)
      piece.writeToArchive();
    // for testing purposes only
//    for (SymbolData terrain : mapBoardData)
//      terrain.writeToArchive();
  }

  @Override
  public boolean isValidImportFile(File f) throws IOException {
    DataInputStream in = null;
    try {
      in = new DataInputStream(new FileInputStream(f));
      boolean valid = in.readUnsignedByte() >= 0xFA;
      in.close();
      return valid;
    }
    finally {
      IOUtils.closeQuietly(in);
    }
  }
}
