/*
 * #%L
 * OME Bio-Formats package for reading and converting biological file formats.
 * %%
 * Copyright (C) 2005 - 2017 Open Microscopy Environment:
 *   - Board of Regents of the University of Wisconsin-Madison
 *   - Glencoe Software, Inc.
 *   - University of Dundee
 * %%
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as
 * published by the Free Software Foundation, either version 2 of the 
 * License, or (at your option) any later version.
 * 
 * This program 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 General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public 
 * License along with this program.  If not, see
 * <http://www.gnu.org/licenses/gpl-2.0.html>.
 * #L%
 */

package loci.formats.in;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.InflaterInputStream;

import loci.common.Constants;
import loci.common.DataTools;
import loci.common.RandomAccessInputStream;
import loci.formats.CoreMetadata;
import loci.formats.FormatException;
import loci.formats.FormatReader;
import loci.formats.FormatTools;
import loci.formats.MetadataTools;
import loci.formats.meta.MetadataStore;
import loci.formats.tools.AmiraParameters;

import ome.units.quantity.Length;

/**
 * This is a file format reader for AmiraMesh data.
 *
 * @author Gregory Jefferis jefferis at gmail.com
 * @author Johannes Schindelin johannes.schindelin at gmx.de
 */
public class AmiraReader extends FormatReader {

  // -- Fields --

  long offsetOfFirstStream;

  // for non-raw plane formats
  transient PlaneReader planeReader;
  private boolean hasPlaneReader = false;
  private String compression;
  private boolean ascii = false;

  // for labels
  byte[][] lut;

  // -- Constructor --

  public AmiraReader() {
    super("Amira", new String[] {"am", "amiramesh", "grey", "hx", "labels"});
    domains = new String[] {FormatTools.UNKNOWN_DOMAIN};
  }

  // -- IFormatReader API methods --

  /* @see loci.formats.IFormatReader#getOptimalTileHeight() */
  @Override
  public int getOptimalTileHeight() {
    FormatTools.assertId(currentId, true, 1);
    return getSizeY();
  }

  /**
   * @see loci.formats.FormatReader#openBytes(int, byte[], int, int, int, int)
   */
  @Override
  public byte[] openBytes(int no, byte[] buf, int x, int y, int w, int h)
    throws FormatException, IOException
  {
    FormatTools.checkPlaneParameters(this, no, buf.length, x, y, w, h);

    int planeSize = FormatTools.getPlaneSize(this);
    if (hasPlaneReader) {
      if (planeReader == null) {
        initPlaneReader();
      }

      // plane readers can only read whole planes, so we need to blit
      int bytesPerPixel = FormatTools.getBytesPerPixel(getPixelType());
      byte[] planeBuf = new byte[planeSize];
      planeReader.read(no, planeBuf);
      int srcWidth = getSizeX() * bytesPerPixel;
      int destWidth = w * bytesPerPixel;
      for (int j = y; j < y + h; j++) {
        int src = j * srcWidth + x * bytesPerPixel;
        int dest = (j - y) * destWidth;
        System.arraycopy(planeBuf, src, buf, dest, destWidth);
      }
    }
    else {
      in.seek(offsetOfFirstStream + (long) no * planeSize);
      readPlane(in, x, y, w, h, buf);
    }

    return buf;
  }

  /* @see loci.formats.FormatReader#close(boolean) */
  @Override
  public void close(boolean fileOnly) throws IOException {
    super.close(fileOnly);

    offsetOfFirstStream = 0;
    planeReader = null;
    hasPlaneReader = false;
    compression = null;
    ascii = false;
    lut = null;
  }

  /* (non-Javadoc)
   * @see loci.formats.FormatReader#initFile(java.lang.String)
   */
  @Override
  protected void initFile(String id) throws FormatException, IOException {
    super.initFile(id);
    in = new RandomAccessInputStream(id);

    AmiraParameters parameters = new AmiraParameters(in);
    offsetOfFirstStream = in.getFilePointer();

    LOGGER.info("Populating metadata hashtable");

    addGlobalMeta("Image width", parameters.width);
    addGlobalMeta("Image height", parameters.height);
    addGlobalMeta("Number of planes", parameters.depth);
    addGlobalMeta("Bits per pixel", 8);

    LOGGER.info("Populating core metadata");

    int channelIndex = 1;
    while (parameters.getStreams().get("@" + channelIndex) != null) {
      channelIndex++;
    }

    CoreMetadata m = core.get(0);

    m.sizeX = parameters.width;
    m.sizeY = parameters.height;
    m.sizeZ = parameters.depth;
    m.sizeT = 1;
    m.sizeC = channelIndex - 1;
    m.imageCount = getSizeZ() * getSizeC();
    m.littleEndian = parameters.littleEndian;
    m.dimensionOrder = "XYZCT";

    String streamType = parameters.streamTypes[0].toLowerCase();
    if (streamType.equals("byte")) {
      m.pixelType = FormatTools.UINT8;
    }
    else if (streamType.equals("short")) {
      m.pixelType = FormatTools.INT16;
      addGlobalMeta("Bits per pixel", 16);
    }
    else if (streamType.equals("ushort")) {
      m.pixelType = FormatTools.UINT16;
      addGlobalMeta("Bits per pixel", 16);
    }
    else if (streamType.equals("int")) {
      m.pixelType = FormatTools.INT32;
      addGlobalMeta("Bits per pixel", 32);
    }
    else if (streamType.equals("float")) {
      m.pixelType = FormatTools.FLOAT;
      addGlobalMeta("Bits per pixel", 32);
    }
    else {
      LOGGER.warn("Assuming data type is byte");
      m.pixelType = FormatTools.UINT8;
    }
    LOGGER.info("Populating metadata store");

    MetadataStore store = makeFilterMetadata();
    MetadataTools.populatePixels(store, this);

    // Note that Amira specifies a bounding box, not pixel sizes.
    // The bounding box is the range of the centre of the voxels
    if (getMetadataOptions().getMetadataLevel() != MetadataLevel.MINIMUM) {
      double pixelWidth = (double) (parameters.x1 - parameters.x0) /
        (parameters.width - 1);
      double pixelHeight = (double) (parameters.y1 - parameters.y0) /
        (parameters.height - 1);
      // TODO - what is correct setting if single slice?
      double pixelDepth = (double) (parameters.z1 - parameters.z0) /
        (parameters.depth - 1);

      // Amira does not have a standard form for encoding units, so we just
      // have to assume microns for microscopy data
      addGlobalMeta("Pixels per meter (X)", 1e6 / pixelWidth);
      addGlobalMeta("Pixels per meter (Y)", 1e6 / pixelHeight);
      addGlobalMeta("Pixels per meter (Z)", 1e6 / pixelDepth);

      Length sizeX = FormatTools.getPhysicalSizeX(pixelWidth);
      Length sizeY = FormatTools.getPhysicalSizeY(pixelHeight);
      Length sizeZ = FormatTools.getPhysicalSizeZ(pixelDepth);

      if (sizeX != null) {
        store.setPixelsPhysicalSizeX(sizeX, 0);
      }
      if (sizeY != null) {
        store.setPixelsPhysicalSizeY(sizeY, 0);
      }
      if (sizeZ != null) {
        store.setPixelsPhysicalSizeZ(sizeZ, 0);
      }
    }

    ascii = parameters.ascii;
    ArrayList streamData = (ArrayList) parameters.getStreams().get("@1");
    if (streamData.size() > 2) {
      compression = (String) streamData.get(2);
    }
    initPlaneReader();
    hasPlaneReader = planeReader != null;

    addGlobalMeta("Compression", compression);

    Map params = (Map) parameters.getMap().get("Parameters");
    if (params != null) {
      Map materials = (Map) params.get("Materials");
      if (materials != null) {
        lut = getLookupTable(materials);
        m.indexed = true;
      }
    }
  }

  private void initPlaneReader() {
    if (ascii) {
      planeReader = new ASCII(getPixelType(), getSizeX() * getSizeY());
    }

    if (compression != null) {
      if (compression.startsWith("HxZip,")) {
        long size = Long.parseLong(compression.substring("HxZip,".length()));
        planeReader = new HxZip(size);
      }
      else if (compression.startsWith("HxByteRLE,")) {
        long size =
          Long.parseLong(compression.substring("HxByteRLE,".length()));
        planeReader = new HxRLE(getSizeZ(), size);
      }
    }
  }

  /* @see loci.formats.IFormatReader#isThisType(RandomAccessInputStream) */
  @Override
  public boolean isThisType(RandomAccessInputStream stream) throws IOException {
    if (!FormatTools.validStream(stream, 50, false)) return false;
    String c = stream.readLine();

    Matcher amiraMeshDef = Pattern.compile("#\\s+(AmiraMesh|Avizo).*?" +
      "(BINARY|ASCII)(-LITTLE-ENDIAN)*").matcher(c);
    return amiraMeshDef.find();
  }

  /* @see IFormatReader#get8BitLookupTable() */
  @Override
  public byte[][] get8BitLookupTable() {
    FormatTools.assertId(currentId, true ,1);
    return lut;
  }

  // -- Helper methods --

  byte[][] getLookupTable(Map materials) throws FormatException {
    byte[][] result = new byte[3][256];
    int i = -1;
    for (Object label : materials.keySet()) {
      i++;
      Object object = materials.get(label);
      if (!(object instanceof Map)) {
        throw new FormatException("Invalid material: " + label);
      }
      Map material = (Map) object;
      object = material.get("Color");
      if (object == null) continue; // black
      if (!(object instanceof Number[])) {
        throw new FormatException("Invalid material: " + label);
      }
      Number[] color = (Number[]) object;
      if (color.length != 3) {
        throw new FormatException("Invalid color: " +
          color.length + " channels");
      }
      for (int j = 0; j < 3; j++) {
        result[j][i] = (byte) (int) (255 * color[j].floatValue());
      }
    }
    return result;
  }

  /**
   * This is the common interface for all formats, compressed or not.
   */
  interface PlaneReader {
    byte[] read(int no, byte[] buf) throws FormatException, IOException;
  }

  /**
   * This class provides a not-quite-efficient, but simple reader for
   * planes stored in ASCII-encoded numbers.
   *
   * Should efficiency ever become a concern, we'll need to have
   * specializations for every pixel type.
   */
  class ASCII implements PlaneReader {
    int pixelType, bytesPerPixel, pixelsPerPlane;
    long[] offsets;
    byte[] numberBuffer = new byte[32];

    ASCII(int pixelType, int pixelsPerPlane) {
      this.pixelType = pixelType;
      this.pixelsPerPlane = pixelsPerPlane;
      bytesPerPixel = FormatTools.getBytesPerPixel(pixelType);
      offsets = new long[getSizeZ() + 1];
      offsets[0] = offsetOfFirstStream;
    }

    @Override
    public byte[] read(int no, byte[] buf) throws FormatException, IOException {
      if (offsets[no] == 0) {
        int i = no - 1;
        while (offsets[i] == 0) {
          i--;
        }
        in.seek(offsets[i]);
        while (i < no) {
          for (int j = 0; j < pixelsPerPlane; j++) {
            readNumberString();
          }
          offsets[++i] = in.getFilePointer();
        }
      }
      else {
        in.seek(offsets[no]);
      }
      for (int j = 0; j < pixelsPerPlane; j++) {
        int offset = j * bytesPerPixel;
        double number = readNumberString();
        long value = pixelType == FormatTools.DOUBLE ?
          Double.doubleToLongBits(number) :
          pixelType == FormatTools.FLOAT ?
          Float.floatToIntBits((float) number) :
          (long) number;
        DataTools.unpackBytes(value, buf, offset, bytesPerPixel, false);
      }
      offsets[no + 1] = in.getFilePointer();
      return buf;
    }

    double readNumberString() throws IOException {
      numberBuffer[0] = skipWhiteSpace();
      for (int i = 1;; i++) {
        byte c = in.readByte();
        if (!(c >= '0' && c <= '9') && c != '.') {
          return Double.parseDouble(
            new String(numberBuffer, 0, i, Constants.ENCODING));
        }
        numberBuffer[i] = c;
      }
    }

    byte skipWhiteSpace() throws IOException {
      for (;;) {
        byte c = in.readByte();
        if (c != ' ' && c != '\t' && c != '\n') {
          return c;
        }
      }
    }
  }

  /**
   * This is the reader for GZip-compressed AmiraMeshes.
   *
   * As such files contain a single GZipped stream for the complete stack,
   * we cannot really access the slices randomly, but have to decompress
   * instead of seeking.
   */
  class HxZip implements PlaneReader {
    long offsetOfStream, compressedSize;
    int currentNo, planeSize;
    transient InflaterInputStream decompressor;

    HxZip(long compressedSize) {
      this.compressedSize = compressedSize;
      planeSize = FormatTools.getPlaneSize(AmiraReader.this);
      offsetOfStream = offsetOfFirstStream;
      currentNo = Integer.MAX_VALUE;
    }

    void initDecompressor() throws IOException {
      currentNo = 0;
      in.seek(offsetOfStream);
      decompressor = new InflaterInputStream(in);
    }

    @Override
    public byte[] read(int no, byte[] buf) throws FormatException, IOException {
      if (no < currentNo) {
        initDecompressor();
      }
      for (; currentNo <= no; currentNo++) {
        int offset = 0, len = planeSize;
        while (len > 0) {
          int count = decompressor.read(buf, offset, len);
          if (count <= 0) return null;
          offset += count;
          len -= count;
        }
      }
      return buf;
    }
  }

  /**
   * This is the reader for RLE-compressed AmiraMeshes.
   */
  class HxRLE implements PlaneReader {
    long compressedSize;
    long[] offsets;
    int[] internalOffsets;
    int currentNo, maxOffsetIndex, planeSize;
    long lastCodeOffset = 0;

    HxRLE(int sliceCount, long compressedSize) {
      this.compressedSize = compressedSize;
      offsets = new long[sliceCount + 1];
      internalOffsets = new int[sliceCount + 1];
      offsets[0] = offsetOfFirstStream;
      internalOffsets[0] = 0;
      planeSize = FormatTools.getPlaneSize(AmiraReader.this);
      maxOffsetIndex = currentNo = 0;
    }

    void read(byte[] buf, int len) throws FormatException, IOException {
      int off = 0;
      while (len > 0 && in.getFilePointer() < in.length()) {
        lastCodeOffset = in.getFilePointer();
        int insn = in.readByte();
        if (insn < 0) {
          insn = (insn & 0x7f);
          if (insn > len) {
            throw new FormatException("Slice " + currentNo + " is unaligned!");
          }
          while (insn > 0) {
            int count = in.read(buf, off, insn);
            if (count < 0) throw new IOException("End of file!");
            insn -= count;
            len -= count;
            off += count;
          }
        }
        else {
          if (insn > len) {
            internalOffsets[currentNo] = len;
            insn = len;
          }
          else if (insn == len) lastCodeOffset += 2;
          if (off == 0 && currentNo > 0 && internalOffsets[currentNo - 1] > 0) {
            insn -= internalOffsets[currentNo - 1];
          }
          Arrays.fill(buf, off, off + insn, in.readByte());
          len -= insn;
          off += insn;
        }
      }
    }

    @Override
    public byte[] read(int no, byte[] buf) throws FormatException, IOException {
      if (maxOffsetIndex < no) {
        in.seek(offsets[maxOffsetIndex]);
        while (maxOffsetIndex <= no) {
          Arrays.fill(buf, getFillColor());
          read(currentNo, buf);
          currentNo++;
        }
      }
      else {
        in.seek(offsets[no]);
        currentNo = no;
        Arrays.fill(buf, getFillColor());
        read(buf, planeSize);
        if (maxOffsetIndex == no) {
          offsets[++maxOffsetIndex] = lastCodeOffset;
        }
      }
      return buf;
    }
  }
}
