/*
 * #%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.File;
import java.io.IOException;
import java.util.Vector;

import loci.common.Location;
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.UnsupportedCompressionException;
import loci.formats.codec.CodecOptions;
import loci.formats.codec.JPEGCodec;
import loci.formats.codec.MJPBCodec;
import loci.formats.codec.MJPBCodecOptions;
import loci.formats.codec.QTRLECodec;
import loci.formats.codec.RPZACodec;
import loci.formats.codec.ZlibCodec;
import loci.formats.meta.MetadataStore;

/**
 * QTReader is the file format reader for QuickTime movie files.
 * It does not require any external libraries to be installed.
 *
 * Video codecs currently supported: raw, rle, jpeg, mjpb, rpza.
 * Additional video codecs will be added as time permits.
 *
 * @author Melissa Linkert melissa at glencoesoftware.com
 */

public class QTReader extends FormatReader {

  // -- Constants --

  /** List of identifiers for each container atom. */
  private static final String[] CONTAINER_TYPES = {
    "moov", "trak", "udta", "tref", "imap", "mdia", "minf", "stbl", "edts",
    "mdra", "rmra", "imag", "vnrp", "dinf"
  };

  // -- Fields --

  /** Offset to start of pixel data. */
  private long pixelOffset;

  /** Total number of bytes of pixel data. */
  private long pixelBytes;

  /** Pixel depth. */
  private int bitsPerPixel;

  /** Raw plane size, in bytes. */
  private int rawSize;

  /** Offsets to each plane's pixel data. */
  private Vector<Integer> offsets;

  /** Pixel data for the previous image plane. */
  private byte[] prevPixels;

  /** Previous plane number. */
  private int prevPlane;

  /** Flag indicating whether we can safely use prevPixels. */
  private boolean canUsePrevious;

  /** Video codec used by this movie. */
  private String codec;

  /** Some movies use two video codecs -- this is the second codec. */
  private String altCodec;

  /** Number of frames that use the alternate codec. */
  private int altPlanes;

  /** Amount to subtract from each offset. */
  private int scale;

  /** Number of bytes in each plane. */
  private Vector<Integer> chunkSizes;

  /** Set to true if the scanlines in a plane are interlaced (mjpb only). */
  private boolean interlaced;

  /** Flag indicating whether the resource and data fork are separated. */
  private boolean separatedFork;
  private String forkFile;

  private boolean flip;

  // -- Constructor --

  /** Constructs a new QuickTime reader. */
  public QTReader() {
    super("QuickTime", "mov");
    suffixNecessary = false;
    domains = new String[] {FormatTools.GRAPHICS_DOMAIN};
  }

  // -- IFormatReader API methods --

  /* @see loci.formats.IFormatReader#isThisType(RandomAccessInputStream) */
  @Override
  public boolean isThisType(RandomAccessInputStream stream) throws IOException {
    final int blockLen = 64;
    if (!FormatTools.validStream(stream, blockLen, false)) return false;
    // use a crappy hack for now
    String s = stream.readString(blockLen);
    for (int i=0; i<CONTAINER_TYPES.length; i++) {
      if (s.indexOf(CONTAINER_TYPES[i]) >= 0 &&
        !CONTAINER_TYPES[i].equals("imag"))
      {
        return true;
      }
    }
    return s.indexOf("wide") >= 0 ||
      s.indexOf("mdat") >= 0 || s.indexOf("ftypqt") >= 0;
  }

  /* @see loci.formats.IFormatReader#getSeriesUsedFiles(boolean) */
  @Override
  public String[] getSeriesUsedFiles(boolean noPixels) {
    FormatTools.assertId(currentId, true, 1);
    if (noPixels) {
      return forkFile == null ? null : new String[] {forkFile};
    }
    return forkFile == null ? new String[] {currentId} :
      new String[] {currentId, forkFile};
  }

  /**
   * @see loci.formats.IFormatReader#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);

    String code = codec;
    if (no >= getImageCount() - altPlanes) code = altCodec;

    int offset = offsets.get(no).intValue();
    int nextOffset = (int) pixelBytes;

    scale = offsets.get(0).intValue();
    offset -= scale;

    if (no < offsets.size() - 1) {
      nextOffset = offsets.get(no + 1).intValue() - scale;
    }

    if ((nextOffset - offset) < 0) {
      int temp = offset;
      offset = nextOffset;
      nextOffset = temp;
    }

    byte[] pixs = new byte[nextOffset - offset];

    in.seek(pixelOffset + offset);
    in.read(pixs);

    canUsePrevious = (prevPixels != null) && (prevPlane == no - 1) &&
      !code.equals(altCodec);

    byte[] t = prevPlane == no && prevPixels != null && !code.equals(altCodec) ?
      prevPixels : uncompress(pixs, code);
    if (code.equals("rpza")) {
      for (int i=0; i<t.length; i++) {
        t[i] = (byte) (255 - t[i]);
      }
      prevPlane = no;
      return buf;
    }

    // on rare occassions, we need to trim the data
    if (canUsePrevious && (prevPixels.length < t.length)) {
      byte[] temp = t;
      t = new byte[prevPixels.length];
      System.arraycopy(temp, 0, t, 0, t.length);
    }

    if (t.length > 0) {
      prevPixels = t;
    }
    prevPlane = no;

    // determine whether we need to strip out any padding bytes

    int bytes = bitsPerPixel < 40 ? bitsPerPixel / 8 : (bitsPerPixel - 32) / 8;
    int pad = (4 - (getSizeX() % 4)) % 4;
    if (codec.equals("mjpb")) pad = 0;

    int expectedSize = FormatTools.getPlaneSize(this);

    if (prevPixels.length == expectedSize ||
      (bitsPerPixel == 32 && (3 * (prevPixels.length / 4)) == expectedSize))
    {
      pad = 0;
    }

    if (pad > 0) {
      t = new byte[prevPixels.length - getSizeY()*pad];

      for (int row=0; row<getSizeY(); row++) {
        System.arraycopy(prevPixels, row * (bytes * getSizeX() + pad), t,
          row * getSizeX() * bytes, getSizeX() * bytes);
      }
    }
    if (t.length == 0) {
      t = prevPixels;
    }

    int bpp = FormatTools.getBytesPerPixel(getPixelType());
    int srcRowLen = getSizeX() * bpp * getSizeC();
    int destRowLen = w * bpp * getSizeC();
    for (int row=0; row<h; row++) {
      if (bitsPerPixel == 32) {
        for (int col=0; col<w; col++) {
          int src = (row + y) * getSizeX() * bpp * 4 + (x + col) * bpp * 4 + 1;
          int dst = row * destRowLen + col * bpp * 3;
          if (src + 3 <= t.length && dst + 3 <= buf.length) {
            System.arraycopy(t, src, buf, dst, 3);
          }
        }
      }
      else {
        System.arraycopy(t, (row + y)*srcRowLen + x*bpp*getSizeC(), buf,
          row*destRowLen, destRowLen);
      }
    }

    if ((bitsPerPixel == 40 || bitsPerPixel == 8) && !code.equals("mjpb")) {
      // invert the pixels
      for (int i=0; i<buf.length; i++) {
        buf[i] = (byte) (255 - buf[i]);
      }
    }
    return buf;
  }

  /* @see loci.formats.IFormatReader#close(boolean) */
  @Override
  public void close(boolean fileOnly) throws IOException {
    super.close(fileOnly);
    if (!fileOnly) {
      offsets = null;
      prevPixels = null;
      codec = altCodec = null;
      pixelOffset = pixelBytes = bitsPerPixel = rawSize = 0;
      prevPlane = altPlanes = 0;
      canUsePrevious = false;
      scale = 0;
      chunkSizes = null;
      interlaced = separatedFork = flip = false;
      forkFile = null;
    }
  }

  // -- Internal FormatReader API methods --

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

    separatedFork = true;
    offsets = new Vector<Integer>();
    chunkSizes = new Vector<Integer>();
    LOGGER.info("Parsing tags");

    parse(0, 0, in.length());

    CoreMetadata m = core.get(0);

    m.imageCount = offsets.size();
    if (chunkSizes.size() < getImageCount() && chunkSizes.size() > 0) {
      m.imageCount = chunkSizes.size();
    }

    LOGGER.info("Populating metadata");

    int bytes = (bitsPerPixel / 8) % 4;
    m.pixelType = bytes == 2 ? FormatTools.UINT16 : FormatTools.UINT8;

    m.sizeZ = 1;
    m.dimensionOrder = "XYCZT";
    m.littleEndian = false;
    m.metadataComplete = true;
    m.indexed = false;
    m.falseColor = false;

    // this handles the case where the data and resource forks have been
    // separated
    if (separatedFork) {
      // first we want to check if there is a resource fork present
      // the resource fork will generally have the same name as the data fork,
      // but will have either the prefix "._" or the suffix ".qtr"
      // (or <filename>/rsrc on a Mac)

      String base = null;
      // it's not enough to just check the first index of "."
      // on Windows in particular, the directory name could contain "." while
      // the file name has no extension
      if (id.indexOf(".", id.lastIndexOf(File.separator) + 1) != -1) {
        base = id.substring(0, id.lastIndexOf("."));
      }
      else base = id;

      Location f = new Location(base + ".qtr");
      LOGGER.debug("Searching for resource fork:");
      if (f.exists()) {
        LOGGER.debug("\t Found: {}", f);
        if (in != null) in.close();
        in = new RandomAccessInputStream(f.getAbsolutePath());
        forkFile = f.getAbsolutePath();

        stripHeader();
        parse(0, 0, in.length());
        m.imageCount = offsets.size();
      }
      else {
        LOGGER.debug("\tAbsent: {}", f);
        f = new Location(id.substring(0,
          id.lastIndexOf(File.separator) + 1) + "._" +
          id.substring(base.lastIndexOf(File.separator) + 1));
        if (f.exists()) {
          LOGGER.debug("\t Found: {}", f);
          if (in != null) in.close();
          in = new RandomAccessInputStream(f.getAbsolutePath());
          forkFile = f.getAbsolutePath();
          stripHeader();
          parse(0, in.getFilePointer(), in.length());
          m.imageCount = offsets.size();
        }
        else {
          LOGGER.debug("\tAbsent: {}", f);
          f = new Location(id + "/..namedfork/rsrc");
          if (f.exists()) {
            LOGGER.debug("\t Found: {}", f);
            if (in != null) in.close();
            in = new RandomAccessInputStream(f.getAbsolutePath());
            forkFile = f.getAbsolutePath();
            stripHeader();
            parse(0, in.getFilePointer(), in.length());
            m.imageCount = offsets.size();
          }
          else {
            LOGGER.debug("\tAbsent: {}", f);
            throw new FormatException("QuickTime resource fork not found. " +
              " To avoid this issue, please flatten your QuickTime movies " +
              "before importing with Bio-Formats.");
          }
        }
      }
      // reset the stream, otherwise openBytes will try to read pixels
      // from the resource fork
      if (in != null) in.close();
      in = new RandomAccessInputStream(currentId);
    }

    m.rgb = bitsPerPixel < 40;
    m.sizeC = isRGB() ? 3 : 1;
    m.interleaved = isRGB();
    m.sizeT = getImageCount();

    // The metadata store we're working with.
    MetadataStore store = makeFilterMetadata();
    MetadataTools.populatePixels(store, this);
  }

  // -- Helper methods --

  /** Parse all of the atoms in the file. */
  private void parse(int depth, long offset, long length)
    throws FormatException, IOException
  {
    while (offset < length) {
      in.seek(offset);

      // first 4 bytes are the atom size
      long atomSize = in.readInt() & 0xffffffffL;

      // read the atom type
      String atomType = in.readString(4);

      // if atomSize is 1, then there is an 8 byte extended size
      if (atomSize == 1) {
        atomSize = in.readLong();
      }

      if (atomSize < 0) {
        LOGGER.warn("QTReader: invalid atom size: {}", atomSize);
      }
      else if (atomSize > in.length()) {
        offset += 4;
        continue;
      }

      LOGGER.debug("Seeking to {}; atomType={}; atomSize={}",
        new Object[] {offset, atomType, atomSize});

      // if this is a container atom, parse the children
      if (isContainer(atomType)) {
        parse(depth++, in.getFilePointer(), offset + atomSize);
      }
      else {
        if (atomSize == 0) atomSize = in.length();
        long oldpos = in.getFilePointer();

        if (atomType.equals("mdat")) {
          // we've found the pixel data
          pixelOffset = in.getFilePointer();
          pixelBytes = atomSize;

          if (pixelBytes > (in.length() - pixelOffset)) {
            pixelBytes = in.length() - pixelOffset;
          }
        }
        else if (atomType.equals("tkhd")) {
          // we've found the dimensions

          in.skipBytes(38);
          int[][] matrix = new int[3][3];

          for (int i=0; i<matrix.length; i++) {
            for (int j=0; j<matrix[0].length; j++) {
              matrix[i][j] = in.readInt();
            }
          }

          // The contents of the matrix we just read determine whether or not
          // we should flip the width and height.  We can check the first two
          // rows of the matrix - they should correspond to the first two rows
          // of an identity matrix.

          // TODO : adapt to use the value of flip
          flip = matrix[0][0] == 0 && matrix[1][0] != 0;

          if (getSizeX() == 0) core.get(0).sizeX = in.readInt();
          if (getSizeY() == 0) core.get(0).sizeY = in.readInt();
        }
        else if (atomType.equals("cmov")) {
          in.skipBytes(8);
          if ("zlib".equals(in.readString(4))) {
            atomSize = in.readInt();
            in.skipBytes(4);
            int uncompressedSize = in.readInt();

            byte[] b = new byte[(int) (atomSize - 12)];
            in.read(b);

            byte[] output = new ZlibCodec().decompress(b, null);

            RandomAccessInputStream oldIn = in;
            try {
              in = new RandomAccessInputStream(output);
              parse(0, 0, output.length);
            } finally {
              in.close();
            }
            in = oldIn;
          }
          else {
            throw new UnsupportedCompressionException(
              "Compressed header not supported.");
          }
        }
        else if (atomType.equals("stco")) {
          // we've found the plane offsets

          if (offsets.size() > 0) break;
          separatedFork = false;
          in.skipBytes(4);
          int numPlanes = in.readInt();
          if (numPlanes != getImageCount()) {
            in.seek(in.getFilePointer() - 4);
            int off = in.readInt();
            offsets.add(off);
            for (int i=1; i<getImageCount(); i++) {
              if ((chunkSizes.size() > 0) && (i < chunkSizes.size())) {
                rawSize = chunkSizes.get(i).intValue();
              }
              else i = getImageCount();
              off += rawSize;
              offsets.add(off);
            }
          }
          else {
            for (int i=0; i<numPlanes; i++) {
              offsets.add(in.readInt());
            }
          }
        }
        else if (atomType.equals("stsd")) {
          // found video codec and pixel depth information

          in.skipBytes(4);
          int numEntries = in.readInt();
          in.skipBytes(4);

          for (int i=0; i<numEntries; i++) {
            if (i == 0) {
              codec = in.readString(4);

              if (!codec.equals("raw ") && !codec.equals("rle ") &&
                !codec.equals("rpza") && !codec.equals("mjpb") &&
                !codec.equals("jpeg"))
              {
                throw new UnsupportedCompressionException(
                  "Unsupported codec: " + codec);
              }

              in.skipBytes(16);
              if (in.readShort() == 0) {
                in.skipBytes(56);

                bitsPerPixel = in.readShort();
                if (codec.equals("rpza")) bitsPerPixel = 8;
                in.skipBytes(10);
                interlaced = in.read() == 2;
                addGlobalMeta("Codec", codec);
                addGlobalMeta("Bits per pixel", bitsPerPixel);
                in.skipBytes(9);
              }
            }
            else {
              altCodec = in.readString(4);
              addGlobalMeta("Second codec", altCodec);
            }
          }
        }
        else if (atomType.equals("stsz")) {
          // found the number of planes
          in.skipBytes(4);
          rawSize = in.readInt();
          core.get(0).imageCount = in.readInt();

          if (rawSize == 0) {
            in.seek(in.getFilePointer() - 4);
            for (int b=0; b<getImageCount(); b++) {
              chunkSizes.add(in.readInt());
            }
          }
        }
        else if (atomType.equals("stsc")) {
          in.skipBytes(4);

          int numChunks = in.readInt();

          if (altCodec != null) {
            int prevChunk = 0;
            for (int i=0; i<numChunks; i++) {
              int chunk = in.readInt();
              int planesPerChunk = in.readInt();
              int id = in.readInt();

              if (id == 2) altPlanes += planesPerChunk * (chunk - prevChunk);

              prevChunk = chunk;
            }
          }
        }
        else if (atomType.equals("stts")) {
          in.skipBytes(12);
          int fps = in.readInt();
          addGlobalMeta("Frames per second", fps);
        }
        if (oldpos + atomSize < in.length()) {
          in.seek(oldpos + atomSize);
        }
        else break;
      }

      if (atomSize == 0) offset = in.length();
      else offset += atomSize;

      // if a 'udta' atom, skip ahead 4 bytes
      if (atomType.equals("udta")) offset += 4;
      print(depth, atomSize, atomType);
    }
  }

  /** Checks if the given String is a container atom type. */
  private boolean isContainer(String type) {
    for (int i=0; i<CONTAINER_TYPES.length; i++) {
      if (type.equals(CONTAINER_TYPES[i])) return true;
    }
    return false;
  }

  /** Debugging method; prints information on an atom. */
  private void print(int depth, long size, String type) {
    final StringBuilder sb = new StringBuilder();
    for (int i=0; i<depth; i++) sb.append(" ");
    sb.append(type + " : [" + size + "]");
    LOGGER.debug(sb.toString());
  }

  /** Uncompresses an image plane according to the the codec identifier. */
  private byte[] uncompress(byte[] pixs, String code)
    throws FormatException, IOException
  {
    CodecOptions options = new MJPBCodecOptions();
    options.width = getSizeX();
    options.height = getSizeY();
    options.bitsPerSample = bitsPerPixel;
    options.channels = bitsPerPixel < 40 ? bitsPerPixel / 8 :
      (bitsPerPixel - 32) / 8;
    options.previousImage = canUsePrevious ? prevPixels : null;
    options.littleEndian = isLittleEndian();
    options.interleaved = isRGB();

    if (code.equals("raw ")) return pixs;
    else if (code.equals("rle ")) {
      return new QTRLECodec().decompress(pixs, options);
    }
    else if (code.equals("rpza")) {
      return new RPZACodec().decompress(pixs, options);
    }
    else if (code.equals("mjpb")) {
      ((MJPBCodecOptions) options).interlaced = interlaced;
      return new MJPBCodec().decompress(pixs, options);
    }
    else if (code.equals("jpeg")) {
      return new JPEGCodec().decompress(pixs, options);
    }
    else {
      throw new UnsupportedCompressionException("Unsupported codec : " + code);
    }
  }

  /** Cut off header bytes from a resource fork file. */
  private void stripHeader() throws IOException {
    in.seek(0);
    while (!in.readString(4).equals("moov")) {
      in.seek(in.getFilePointer() - 2);
    }
    in.seek(in.getFilePointer() - 8);
  }

}
