/*
 * #%L
 * BSD implementations of Bio-Formats readers and writers
 * %%
 * Copyright (C) 2005 - 2017 Open Microscopy Environment:
 *   - Board of Regents of the University of Wisconsin-Madison
 *   - Glencoe Software, Inc.
 *   - University of Dundee
 * %%
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * 1. Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 * #L%
 */
package loci.formats.in;

import java.io.IOException;
import java.util.Arrays;
import java.util.Iterator;
import javax.xml.parsers.ParserConfigurationException;

import loci.common.DataTools;
import loci.common.RandomAccessInputStream;
import loci.common.xml.XMLTools;
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.tiff.IFD;
import loci.formats.tiff.TiffParser;

import org.xml.sax.SAXException;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

/**
 * @author Lee Kamentsky
 * 
 * Reader for the Amris ImageStream / FlowSight file format
 * 
 * The cif file format is tiff-like, but doesn't adhere to
 * the TIFF standard, so we use the TiffParser where we can, 
 * but do not use the TiffReader hierarchy.
 */
public class FlowSightReader extends FormatReader {
  final private static int CHANNEL_COUNT_TAG = 33000;
  final private static int ACQUISITION_TIME_TAG = 33004;
  final private static int CHANNEL_NAMES_TAG = 33007;
  final private static int CHANNEL_DESCS_TAG = 33008;
  final private static int METADATA_XML_TAG = 33027;

  final private static int GREYSCALE_COMPRESSION = 30817;
  final private static int BITMASK_COMPRESSION = 30818;

  /**
   * The tags that must be present for this reader
   * to function properly
   */
  final private static int [] MINIMAL_TAGS = {
    METADATA_XML_TAG
  };

  private transient TiffParser tiffParser;
  private long [] ifdOffsets;

  private String [] channelNames;
  private String [] channelDescs;

  public FlowSightReader() {
    super("FlowSight", "cif");
  }

  /* (non-Javadoc)
   * @see loci.formats.FormatReader#isThisType(loci.common.RandomAccessInputStream)
   */
  @Override
  public boolean isThisType(RandomAccessInputStream stream)
          throws IOException {
    TiffParser tiffParser;
    try {
      tiffParser = new TiffParser(stream);
    } catch (java.lang.IllegalArgumentException iae) {
      return false;
    }
    if (! tiffParser.isValidHeader()) return false;
    IFD ifd = tiffParser.getFirstIFD();
    if (ifd == null) return false;
    tiffParser.fillInIFD(ifd);
    for (int tag: MINIMAL_TAGS) {
      try {
        if (ifd.getIFDStringValue(tag) == null) return false;
      } catch (FormatException e) {
        return false;
      }
    }
    return true;
  }

  /* (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);
    tiffParser = new TiffParser(in);
    tiffParser.setDoCaching(false);
    tiffParser.setUse64BitOffsets(false);
    final Boolean littleEndian = tiffParser.checkHeader();
    if (littleEndian == null) {
      throw new FormatException("Invalid FlowSight file");
    }
    final boolean little = littleEndian.booleanValue();
    in.order(little);

    LOGGER.info("Reading IFDs");

    ifdOffsets = tiffParser.getIFDOffsets();

    if (ifdOffsets.length < 2) {
      throw new FormatException("No IFDs found");
    }

    LOGGER.info("Populating metadata");

    /*
     * The first IFD contains file-scope metadata
     */
    final IFD ifd0 = tiffParser.getFirstIFD();
    tiffParser.fillInIFD(ifd0);
    int channelCount = ifd0.getIFDIntValue(CHANNEL_COUNT_TAG, 1);
    final String channelNamesString = ifd0.getIFDStringValue(CHANNEL_NAMES_TAG);
    if (channelNamesString != null) {
      channelNames = channelNamesString.split("\\|");
      if (channelNames.length != channelCount) {
        if (LOGGER.isDebugEnabled()) {
          LOGGER.debug("Channel count (%d) does not match number of " +
              "channel names (%d) in string \"%s\"",
              channelCount, channelNames.length, channelNamesString);
        }
        channelCount = channelNames.length;
      }
      LOGGER.debug("Found {} channels: {}",
          channelCount, channelNamesString.replace('|', ','));
    }
    final String channelDescsString = ifd0.getIFDStringValue(CHANNEL_DESCS_TAG);
    if (channelDescsString != null) {
      channelDescs = channelDescsString.split("\\|");
      if (channelDescs.length != channelCount) {
        throw new FormatException(String.format(
            "Channel count (%d) does not match number of channel descriptions (%d) in string \"%s\"",
            channelCount, channelDescs.length, channelDescsString));
      }
    }

    String xml = ifd0.getIFDTextValue(METADATA_XML_TAG);
    xml = XMLTools.sanitizeXML(xml);

    try {
      Element xmlRoot = XMLTools.parseDOM(xml).getDocumentElement();

      NodeList imagingNodes = xmlRoot.getElementsByTagName("Imaging");
      if (imagingNodes.getLength() > 0) {
        Element imagingNode = (Element) imagingNodes.item(0);
        NodeList children = imagingNode.getChildNodes();
        for (int child=0; child<children.getLength(); child++) {
          Node childNode = children.item(child);
          String name = childNode.getNodeName();
          if (name.startsWith("ChannelInUseIndicators")) {
            channelCount = 0;
            String text = childNode.getTextContent();
            String[] tokens = text.split(" ");
            for (String token : tokens) {
              if (token.equals("1")) {
                channelCount++;
              }
            }
          }
        }
      }
    }
    catch (ParserConfigurationException e) {
      LOGGER.debug("Could not parse XML", e);
    }
    catch (SAXException e) {
      LOGGER.debug("Could not parse XML", e);
    }

    /*
     * Scan the remaining IFDs
     * 
     * Unfortunately, each image can have a different width and height
     * and the images and masks have a different bit depth, so in the
     * OME scheme of things, we get one series per plane.
     */
    for (int idxOff=1; idxOff<ifdOffsets.length;idxOff++) {
      // TODO: Record the channel names
      final long offset = ifdOffsets[idxOff];
      final boolean first=(idxOff == 1);
      final IFD ifd = tiffParser.getIFD(offset);
      tiffParser.fillInIFD(ifd);
      CoreMetadata ms = first ? core.get(0) : new CoreMetadata();
      ms.rgb = false;
      ms.interleaved = false;
      ms.littleEndian = ifd0.isLittleEndian();
      ms.sizeX = (int) ifd.getImageWidth() / channelCount;
      ms.sizeY = (int) ifd.getImageLength();
      ms.sizeZ = 1;
      ms.sizeC = channelCount;
      ms.sizeT = 1;
      ms.indexed = false;
      ms.dimensionOrder = "XYCZT";
      ms.bitsPerPixel = ifd.getIFDIntValue(IFD.BITS_PER_SAMPLE);
      ms.pixelType = (ms.bitsPerPixel == 8) ? FormatTools.UINT8 : FormatTools.UINT16;
      ms.imageCount = channelCount;
      ms.resolutionCount = 1;
      ms.thumbnail = false;
      ms.metadataComplete = true;
      if (! first) {
        core.add(ms);
      }
    }
    /*
     * Run through the metadata store, setting the channel names
     * for all the series.
     */
    final MetadataStore store = getMetadataStore();
    MetadataTools.populatePixels(store, this);

    if (channelNames != null && channelDescs != null) {
      String [] maskDescs = new String [channelCount];
      for (int i=0; i<channelCount; i++) {
        maskDescs[i] = channelDescs[i] + " Mask";
      }
      for (int series=0; series < ifdOffsets.length-1; series++) {
        final boolean isMask = core.get(series).pixelType == FormatTools.UINT8;
        String[] descs = isMask ? maskDescs : channelDescs;
        for (int channel=0; channel < channelCount; channel++) {
          store.setChannelName(descs[channel], series, channel);
          String cid = MetadataTools.createLSID("Channel", series, channel) + ":";
          store.setChannelID(cid + channelNames[channel], series, channel);
        }
      }
    }
  }

  @Override
  public void close(boolean fileOnly) throws IOException {
    super.close(fileOnly);
    tiffParser = null;
    ifdOffsets = null;
    channelNames = null;
    channelDescs = null;
  }

  @Override
  public void reopenFile() throws IOException {
    super.reopenFile();
    tiffParser = new TiffParser(in);
    tiffParser.setDoCaching(false);
    tiffParser.setUse64BitOffsets(false);
  }

  @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);

    final int idx = getSeries() + 1;
    final IFD ifd = tiffParser.getIFD(ifdOffsets[idx]);
    final int imageWidth = (int) (ifd.getImageWidth());
    final int imageHeight = (int) (ifd.getImageLength());
    final int wOff = x + no * imageWidth / getSizeC();
    if (x+w > imageWidth / getSizeC()) {
      throw new FormatException("Requested tile dimensions extend beyond those of the image.");
    }
    final int compression = ifd.getIFDIntValue(IFD.COMPRESSION);
    byte [] tempBuffer; /* NB - these images are very small */
    switch(compression) {
      case GREYSCALE_COMPRESSION:
        tempBuffer = openGreyscaleBytes(ifd, imageWidth, imageHeight);
        break;
      case BITMASK_COMPRESSION:
        tempBuffer = openBitmaskBytes(ifd, imageWidth, imageHeight);
        break;
      default:
        throw new FormatException(String.format("Unknown compression code: %d", compression));
    }
    final int bytesPerSample = ifd.getIFDIntValue(IFD.BITS_PER_SAMPLE) / 8;
    for (int yy=y; yy<y+h; yy++) {
      final int srcOff = bytesPerSample * (wOff + yy * imageWidth);
      final int destOff = bytesPerSample * (yy-y) * w;
      System.arraycopy(tempBuffer, srcOff, buf, destOff, w * bytesPerSample);
    }
    return buf;
  }

  /**
   * Decode the whole IFD plane using bitmask compression
   * 
   * @param ifd - the IFD to decode
   * @param imageWidth the width of the IFD plane
   * @param imageHeight the height of the IFD plane
   * @return a byte array of length imageWidth * imageHeight
   *         containing the uncompressed data
   * @throws FormatException 
   */
  private byte[] openBitmaskBytes(IFD ifd, int imageWidth, int imageHeight) throws FormatException {
    final byte [] uncompressed = new byte[imageWidth * imageHeight];
    final long [] stripByteCounts = ifd.getIFDLongArray(IFD.STRIP_BYTE_COUNTS);
    final long [] stripOffsets = ifd.getIFDLongArray(IFD.STRIP_OFFSETS);
    int off = 0;
    for (int i=0; i<stripByteCounts.length; i++) {
      try {
        in.seek(stripOffsets[i]);
        for (int j=0; j<stripByteCounts[i]; j+=2) {
          byte value = in.readByte();
          int runLength = (in.readByte() & 0xFF)+1;
          if (off + runLength > uncompressed.length) {
            throw new FormatException("Unexpected buffer overrun encountered when decompressing bitmask data");
          }
          Arrays.fill(uncompressed, off, off+runLength, value);
          off += runLength;
        }
      } catch (IOException e) {
        LOGGER.error("Caught exception while reading bitmask IFD data", e);
        throw new FormatException(String.format("Error in FlowSight file format: %s", e.getMessage()));
      }
    }
    if (off != uncompressed.length) throw new FormatException("Buffer shortfall encountered when decompressing bitmask data");
    return uncompressed;
  }

  /**
   * Decode the whole IFD plane using greyscale compression
   * 
   * @param ifd
   * @param imageWidth
   * @param imageHeight
   * @return a byte array
   * @throws FormatException 
   */
  private byte[] openGreyscaleBytes(final IFD ifd, final int imageWidth, final int imageHeight) throws FormatException {
    final FormatException [] formatException = new FormatException[1];
    final long [] stripByteCounts = ifd.getIFDLongArray(IFD.STRIP_BYTE_COUNTS);
    final long [] stripOffsets = ifd.getIFDLongArray(IFD.STRIP_OFFSETS);
    Iterator<Short> diffs = new Iterator<Short> () {
      int index = -1;
      int offset = 0;
      int count = 0;
      byte currentByte;
      int nibbleIdx = 2;
      short value = 0;
      short shift = 0;
      boolean bHasNext = (formatException[0] != null);
      boolean loaded = bHasNext;

      @Override
      public boolean hasNext() {
        if (loaded) return bHasNext;
        shift = 0;
        value = 0;
        while (! loaded) {
          if (shift > 15) {
            // this implies that all subsequently read bits will be cleared
            LOGGER.error("Unexpected bit shift value {}, image may be truncated", shift);
            return false;
          }
          byte nibble;
          try {
            nibble = getNextNibble();

            // these 2 additions are safe since shift is 15 or less at this point
            // due to check at top of loop
            short lowBits = (short) (nibble & 0x7);
            value += (short) (lowBits << shift);
            shift += (short) 3;

            if ((nibble & 0x8) == 0) {
              loaded = true;
              bHasNext = true;
              if ((nibble & 0x4) != 0) {
                /*
                 * The number is negative
                 * and the bits at 1 << shift and above
                 * should all be "1". This does it.
                 */
                value |= - (1 << shift);
              }
            }
          } catch (IOException e) {
            LOGGER.error("IOException during read of greyscale image", e);
            formatException[0] = new FormatException(
                String.format("Error in FlowSight format: %s", e.getMessage()));
            loaded = true;
            bHasNext = false;
          } catch (FormatException e) {
            LOGGER.error("Format exception during read of greyscale image", e);
            formatException[0] = e;
            loaded = true;
            bHasNext = false;
          }
        }
        return bHasNext;
      }

      private byte getNextNibble() throws IOException, FormatException {
        if (nibbleIdx >= 2) {
          if (! getNextByte()) {
            return (byte)0xff;
          }
          nibbleIdx = 0;
        }
        if (nibbleIdx++ == 0) {
          return (byte)(currentByte & 0x0f);
        } else {
          return (byte)(currentByte >> 4);
        }
      }

      private boolean getNextByte() throws IOException, FormatException {
        while (offset == count) {
          index++;
          if (index == stripByteCounts.length) {
            loaded = true;
            bHasNext = false;
            return false;
          }
          in.seek(stripOffsets[index]);
          offset = 0;
          count = (int)stripByteCounts[index];
        }
        currentByte = in.readByte();
        offset++;
        return true;
      }

      @Override
      public Short next() {
        if (! hasNext()) throw new IndexOutOfBoundsException("Tried to read past end of IFD data");
        loaded = false;
        return value;
      }

      @Override
      public void remove() {
        throw new UnsupportedOperationException();
      }
    };
    byte [] buffer = new byte [imageWidth * imageHeight * 2];
    short [] lastRow = new short[imageWidth];
    short [] thisRow = new short[imageWidth];
    int index = 0;
    for (int y=0; y<imageHeight; y++) {
      for (int x = 0; x<imageWidth; x++) {
        if (x != 0) {
          thisRow[x] = (short)(diffs.next() + lastRow[x] + thisRow[x-1] - lastRow[x-1]);
        } else {
          thisRow[x] = (short)(diffs.next() + lastRow[x]);
        }
        DataTools.unpackBytes(thisRow[x], buffer, index, 2, in.isLittleEndian());
        index += 2;
      }
      final short [] temp = lastRow;
      lastRow = thisRow;
      thisRow = temp;
    }
    return buffer;
  }

}
