/*
 * #%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.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.commons.lang.ArrayUtils;

import loci.common.DataTools;
import loci.common.DateTools;
import loci.common.Location;
import loci.common.RandomAccessInputStream;
import loci.common.Region;
import loci.formats.CoreMetadata;
import loci.formats.FilePattern;
import loci.formats.FormatException;
import loci.formats.FormatReader;
import loci.formats.FormatTools;
import loci.formats.ImageTools;
import loci.formats.MetadataTools;
import loci.formats.codec.BitWriter;
import loci.formats.meta.MetadataStore;
import loci.formats.tiff.IFD;
import loci.formats.tiff.TiffParser;

import ome.xml.model.primitives.Color;
import ome.xml.model.primitives.NonNegativeInteger;
import ome.xml.model.primitives.PositiveInteger;
import ome.xml.model.primitives.Timestamp;

import ome.units.quantity.Length;
import ome.units.quantity.Time;
import ome.units.UNITS;

/**
 * MIASReader is the file format reader for Maia Scientific MIAS-2 datasets.
 *
 * @author Melissa Linkert melissa at glencoesoftware.com
 */
public class MIASReader extends FormatReader {

  // -- Fields --

  /** TIFF files - indexed by well and file. */
  private String[][] tiffs;

  /** Delegate readers. */
  private MinimalTiffReader[][] readers;

  /** Path to file containing analysis results for all plates. */
  private String resultFile = null;

  private List<AnalysisFile> analysisFiles;
  private List<AnalysisFile> roiFiles;

  private int[] wellNumber;

  private int tileRows, tileCols;
  private int tileWidth, tileHeight;

  private int wellColumns;
  private int[] bpp;

  private String templateFile;

  private final Map<String, String> overlayFiles =
    new HashMap<String, String>();
  private final Map<String, Integer> overlayPlanes =
    new HashMap<String, Integer>();

  /** Whether or not mask pixel data should be parsed in setId. */
  private boolean parseMasks = false;

  /** Cached tile buffer to avoid re-allocations when reading tiles. */
  private byte[] cachedTileBuffer;

  // -- Constructor --

  /** Constructs a new MIAS reader. */
  public MIASReader() {
    super("MIAS", new String[] {"tif", "tiff", "txt"});
    suffixSufficient = false;
    domains = new String[] {FormatTools.HCS_DOMAIN};
    hasCompanionFiles = true;
    datasetDescription = "One directory per plate containing one directory " +
      "per well, each with one or more .tif/.tiff files";
  }

  // -- IFormatReader API methods --

  /* @see loci.formats.IFormatReader#getRequiredDirectories(String[]) */
  @Override
  public int getRequiredDirectories(String[] files)
    throws FormatException, IOException
  {
    final StringBuilder commonParent = new StringBuilder();

    int dirCount = 0;

    String[] dirs = files[0].split(File.separatorChar == '/' ? "/" : "\\\\");
    for (String dir : dirs) {
      boolean canAppend = true;
      for (String f : files) {
        if (!f.startsWith(commonParent.toString() + dir)) {
          canAppend = false;
          break;
        }
      }

      if (canAppend) {
        commonParent.append(dir);
        commonParent.append(File.separator);
        dirCount++;
      }
    }

    int maxDirCount = 0;
    for (String f : files) {
      int parentDirCount =
        f.split(File.separatorChar == '/' ? "/" : "\\\\").length - 1;
      if (parentDirCount > maxDirCount) {
        maxDirCount = parentDirCount;
      }
    }

    return (int) Math.max(3 - (maxDirCount - dirCount), 0);
  }

  /* @see loci.formats.IFormatReader#isSingleFile(String) */
  @Override
  public boolean isSingleFile(String id) throws FormatException, IOException {
    return false;
  }

  /* @see loci.formats.IFormatReader#isThisType(String, boolean) */
  @Override
  public boolean isThisType(String filename, boolean open) {
    if (!open) return super.isThisType(filename, open); // no file system access

    Location baseFile = new Location(filename).getAbsoluteFile();
    Location wellDir = baseFile.getParentFile();
    String wellName = wellDir.getName();

    if (checkSuffix(filename, "txt")) {
      String name = baseFile.getName();
      return wellName.equals("results") || wellName.equals("Batchresults") ||
        name.equals("Nugenesistemplate.txt") || name.startsWith("mode");
    }

    Location experiment = null;
    try {
      experiment = wellDir.getParentFile().getParentFile();
    }
    catch (NullPointerException e) { }

    if (experiment == null) return false;

    boolean validName =
      wellName.startsWith("Well") || wellName.equals("results") ||
      (wellName.length() == 1 && wellName.replaceAll("\\d", "").length() == 0);
    return validName && super.isThisType(filename, open);
  }

  /* @see loci.formats.IFormatReader#isThisType(RandomAccessInputStream) */
  @Override
  public boolean isThisType(RandomAccessInputStream stream) throws IOException {
    TiffParser tp = new TiffParser(stream);
    IFD ifd = tp.getFirstIFD();
    if (ifd == null) return false;

    Object s = ifd.getIFDValue(IFD.SOFTWARE);
    if (s == null) return false;
    String software = null;
    if (s instanceof String[]) software = ((String[]) s)[0];
    else software = s.toString();

    return software.startsWith("eaZYX") || software.startsWith("SCIL_Image") ||
      software.startsWith("IDL");
  }

  /* @see loci.formats.IFormatReader#fileGroupOption(String) */
  @Override
  public int fileGroupOption(String id) throws FormatException, IOException {
    return FormatTools.MUST_GROUP;
  }

  /* @see loci.formats.IFormatReader#get8BitLookupTable() */
  @Override
  public byte[][] get8BitLookupTable() throws FormatException, IOException {
    FormatTools.assertId(currentId, true, 1);
    if (readers == null || readers[0][0].getCurrentFile() == null) {
      return null;
    }
    return readers[0][0].get8BitLookupTable();
  }

  /* @see loci.formats.IFormatReader#get16BitLookupTable() */
  @Override
  public short[][] get16BitLookupTable() throws FormatException, IOException {
    FormatTools.assertId(currentId, true, 1);
    if (readers == null || readers[0][0].getCurrentFile() == null) {
      return null;
    }
    return readers[0][0].get16BitLookupTable();
  }

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

    if (tileRows == 1 && tileCols == 1) {
      readers[getSeries()][no].setId(tiffs[getSeries()][no]);
      readers[getSeries()][no].openBytes(0, buf, x, y, w, h);
      readers[getSeries()][no].close();
      return buf;
    }

    int outputRowLen = w * bpp[getSeries()];

    Region image = new Region(x, y, w, h);
    int outputRow = 0, outputCol = 0;
    Region intersection = null;

    byte[] tileBuf = null;

    for (int row=0; row<tileRows; row++) {
      for (int col=0; col<tileCols; col++) {
        Region tile =
          new Region(col * tileWidth, row * tileHeight, tileWidth, tileHeight);
        if (!tile.intersects(image)) continue;

        intersection = tile.intersection(image);

        int tileIndex = (no * tileRows + row) * tileCols + col;
        tileBuf = getTile(getSeries(), no, row, col, intersection);

        int rowLen = tileBuf.length / intersection.height;

        // copy tile into output image
        int outputOffset = outputRow * outputRowLen + outputCol;
        for (int trow=0; trow<intersection.height; trow++) {
          System.arraycopy(tileBuf, trow * rowLen, buf, outputOffset, rowLen);
          outputOffset += outputRowLen;
        }

        outputCol += rowLen;
      }
      if (intersection != null) {
        outputRow += intersection.height;
        outputCol = 0;
      }
    }

    return buf;
  }

  /* @see loci.formats.IFormatReader#getSeriesUsedFiles(boolean) */
  @Override
  public String[] getSeriesUsedFiles(boolean noPixels) {
    FormatTools.assertId(currentId, true, 1);
    final List<String> files = new ArrayList<String>();

    if (!noPixels && tiffs != null) {
      String[] f = new String[tiffs[getSeries()].length];
      System.arraycopy(tiffs[getSeries()], 0, f, 0, f.length);
      Arrays.sort(f);
      files.addAll(Arrays.asList(f));
    }

    if (analysisFiles != null) {
      for (AnalysisFile file : analysisFiles) {
        if (file.plate <= 0 && (file.well == getSeries() || file.well < 0 ||
          wellNumber[getSeries()] == file.well))
        {
          files.add(file.filename);
        }
      }
    }

    if (templateFile != null) files.add(templateFile);
    return files.toArray(new String[files.size()]);
  }

  /* @see loci.formats.IFormatReader#close(boolean) */
  @Override
  public void close(boolean fileOnly) throws IOException {
    super.close(fileOnly);
    if (readers != null) {
      for (MinimalTiffReader[] images : readers) {
        for (MinimalTiffReader r : images) {
          if (r != null) r.close(fileOnly);
        }
      }
    }
    if (!fileOnly) {
      readers = null;
      tiffs = null;
      tileRows = tileCols = 0;
      resultFile = null;
      analysisFiles = null;
      wellNumber = null;
      tileWidth = tileHeight = 0;
      wellColumns = 0;
      bpp = null;
      cachedTileBuffer = null;
      templateFile = null;
      overlayFiles.clear();
      overlayPlanes.clear();
      roiFiles = null;
    }
  }

  /* @see loci.formats.IFormatReader#getOptimalTileWidth() */
  @Override
  public int getOptimalTileWidth() {
    FormatTools.assertId(currentId, true, 1);
    try {
      readers[0][0].setId(tiffs[0][0]);
      return readers[0][0].getOptimalTileWidth();
    }
    catch (FormatException e) {
      LOGGER.debug("", e);
    }
    catch (IOException e) {
      LOGGER.debug("", e);
    }
    return super.getOptimalTileWidth();
  }

  /* @see loci.formats.IFormatReader#getOptimalTileHeight() */
  @Override
  public int getOptimalTileHeight() {
    FormatTools.assertId(currentId, true, 1);
    try {
      readers[0][0].setId(tiffs[0][0]);
      return readers[0][0].getOptimalTileHeight();
    }
    catch (FormatException e) {
      LOGGER.debug("", e);
    }
    catch (IOException e) {
      LOGGER.debug("", e);
    }
    return super.getOptimalTileHeight();
  }

  // -- Internal FormatReader API methods --

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

    if (checkSuffix(id, "txt")) {
      // first need to find a relevant TIFF file

      Location base = new Location(id).getAbsoluteFile();
      Location plate = null;
      if (base.getParentFile().getName().equals("Batchresults")) {
        Location experiment = base.getParentFile().getParentFile();
        String[] plates = experiment.list(true);
        Arrays.sort(plates);
        plate = new Location(experiment, plates[0]);
      }
      else {
        plate = base.getParentFile();
        if (plate.getName().equals("results")) plate = plate.getParentFile();
      }

      String[] list = plate.list(true);
      Arrays.sort(list);
      for (String f : list) {
        if (f.startsWith("Well")) {
          Location well = new Location(plate, f);
          String[] wellList = well.list(true);
          Arrays.sort(wellList);
          for (String file : wellList) {
            String path = new Location(well, file).getAbsolutePath();
            if (isThisType(path) &&
              checkSuffix(path, new String[] {"tif", "tiff"}))
            {
              initFile(path);
              return;
            }
          }
        }
      }

      throw new FormatException("Could not locate an appropriate TIFF file.");
    }

    if (!isGroupFiles()) {
      tiffs = new String[][] {{id}};
      readers = new MinimalTiffReader[1][1];
      readers[0][0] = new MinimalTiffReader();

      TiffReader r = new TiffReader();
      r.setMetadataStore(getMetadataStore());
      r.setId(tiffs[0][0]);
      core = new ArrayList<CoreMetadata>(r.getCoreMetadataList());
      metadataStore = r.getMetadataStore();

      final Map<String, Object> globalMetadata = r.getGlobalMetadata();
      for (final Map.Entry<String, Object> entry : globalMetadata.entrySet()) {
        addGlobalMeta(entry.getKey(), entry.getValue());
      }

      r.close();

      tileRows = 1;
      tileCols = 1;

      return;
    }

    analysisFiles = new ArrayList<AnalysisFile>();

    // MIAS is a high content screening format which supports multiple plates,
    // wells and fields.
    // Most of the metadata comes from the directory hierarchy, as very little
    // metadata is present in the actual files.
    //
    // The directory hierarchy is either:
    //
    // <experiment name>                        top level experiment directory
    //   Batchresults                           analysis results for experiment
    //   <plate number>_<plate barcode>         one directory for each plate
    //     results                              analysis results for plate
    //     Well<xxxx>                           one directory for each well
    //       mode<x>_z<xxx>_t<xxx>_im<x>_<x>.tif
    //
    // or:
    //
    // <experiment name>                        top level experiment directory
    //   <plate number>                         plate directory (3 digits)
    //     <well number>                        well directory (4 digits)
    //       <channel number>                   channel directory (1 digit)
    //         <tile row>_<tile col>_<Z>_<T>.tif
    //
    // Each TIFF file contains a single grayscale plane.  The "mode" block
    // refers to the channel number; the "z" and "t" blocks refer to the
    // Z section and timepoint, respectively.  The "im<x>_<x>" block gives
    // the row and column coordinates of the image within a mosaic.
    //
    // We are initially given one of these TIFF files; from there, we need
    // to find the top level experiment directory and work our way down to
    // determine how many plates and wells are present.

    LOGGER.info("Building list of TIFF files");

    Location baseFile = new Location(id).getAbsoluteFile();
    Location plate = baseFile.getParentFile().getParentFile();

    String plateName = plate.getName();
    if (!(plateName.length() == 3 || (plateName.length() > 3 &&
      plateName.replaceAll("\\d", "").startsWith("-"))))
    {
      plate = plate.getParentFile();
      plateName = plate.getName();
    }

    int plateNumber = Integer.parseInt(plateName.substring(0, 3));

    Location experiment = plate.getParentFile();
    String[] directories = experiment.list(true);
    Arrays.sort(directories);
    for (String dir : directories) {
      Location f = new Location(experiment, dir);
      if (dir.equals("Batchresults")) {
        String[] results = f.list(true);
        Arrays.sort(results);
        for (String result : results) {
          Location file = new Location(f, result);
          if (result.startsWith("NEO_Results")) {
            resultFile = file.getAbsolutePath();
            AnalysisFile af = new AnalysisFile();
            af.filename = resultFile;
            analysisFiles.add(af);
          }
          else if (result.startsWith("NEO_PlateOutput_")) {
            int plateIndex = Integer.parseInt(result.substring(16, 19));
            if (plateIndex == plateNumber) {
              AnalysisFile af = new AnalysisFile();
              af.filename = file.getAbsolutePath();
              af.plate = 0;
              analysisFiles.add(af);
            }
          }
        }
      }
    }

    String[] list = plate.list(true);
    Arrays.sort(list);
    final List<String> wellDirectories = new ArrayList<String>();
    for (String dir : list) {
      Location f = new Location(plate, dir);
      if (f.getName().startsWith("Well") || f.getName().length() == 4) {
        // directory name is valid, but we need to make sure that the
        // directory contains a TIFF or a subdirectory
        String[] wellList = f.list(true);
        if (wellList != null) {
          Arrays.sort(wellList);
          boolean validWell = false;
          for (String potentialTIFF : wellList) {
            if (potentialTIFF.toLowerCase().endsWith(".tif") ||
              new Location(f, potentialTIFF).isDirectory()) {
              validWell = true;
              break;
            }
          }
          if (validWell) wellDirectories.add(f.getAbsolutePath());
        }
      }
      else if (f.getName().equals("results")) {
        String[] resultsList = f.list(true);
        Arrays.sort(resultsList);
        for (String result : resultsList) {
          // exclude proprietary program state files
          if (!result.endsWith(".sav") && !result.endsWith(".dsv") &&
            !result.endsWith(".dat"))
          {
            Location r = new Location(f, result);
            AnalysisFile af = new AnalysisFile();
            af.filename = r.getAbsolutePath();
            af.plate = 0;
            if (result.toLowerCase().startsWith("well")) {
              af.well = Integer.parseInt(result.substring(4, 8)) - 1;
            }
            analysisFiles.add(af);
          }
        }
      }
      else if (f.getName().equals("Nugenesistemplate.txt")) {
        templateFile = f.getAbsolutePath();
      }
    }

    int nWells = wellDirectories.size();

    LOGGER.debug("Found {} wells.", nWells);

    readers = new MinimalTiffReader[nWells][];
    tiffs = new String[nWells][];
    int[] zCount = new int[nWells];
    int[] cCount = new int[nWells];
    int[] tCount = new int[nWells];
    String[] order = new String[nWells];
    wellNumber = new int[nWells];

    String[] wells = wellDirectories.toArray(new String[nWells]);
    Arrays.sort(wells);

    for (int j=0; j<nWells; j++) {
      Location well = new Location(wells[j]);
      String wellName = well.getName().replaceAll("Well", "");
      wellNumber[j] = Integer.parseInt(wellName) - 1;

      String[] tiffFiles = well.list(true);
      Arrays.sort(tiffFiles);
      final List<String> tmpFiles = new ArrayList<String>();
      for (String tiff : tiffFiles) {
        String name = tiff.toLowerCase();
        if (name.endsWith(".tif") || name.endsWith(".tiff")) {
          tmpFiles.add(new Location(well, tiff).getAbsolutePath());
        }
      }

      if (tmpFiles.size() == 0) {
        LOGGER.debug("No TIFFs in well directory {}", wells[j]);
        // no TIFFs in the well directory, so there are probably channel
        // directories which contain the TIFFs
        for (String dir : tiffFiles) {
          Location file = new Location(well, dir);
          if (dir.length() == 1 && file.isDirectory()) {
            cCount[j]++;

            String[] tiffs = file.list(true);
            Arrays.sort(tiffs);
            for (String tiff : tiffs) {
              String name = tiff.toLowerCase();
              if (name.endsWith(".tif") || name.endsWith(".tiff")) {
                tmpFiles.add(new Location(file, tiff).getAbsolutePath());
              }
            }
          }
        }
      }

      tiffFiles = tmpFiles.toArray(new String[0]);
      if (ArrayUtils.isEmpty(tiffFiles)){
        throw new FormatException("Empty dataset - No tiff files were found.");
      }

      Location firstTiff = new Location(tiffFiles[0]);

      List<String> names = new ArrayList<String>();
      Location parentFile = firstTiff.getParentFile();
      String[] allFiles = parentFile.list();
      Arrays.sort(allFiles);
      for (String f : allFiles) {
        names.add(new Location(parentFile, f).getName());
      }

      FilePattern fp = new FilePattern(FilePattern.findPattern(
        firstTiff.getName(), null, names.toArray(new String[names.size()])));
      String[] blocks = fp.getPrefixes();

      order[j] = "XY";

      int[] count = fp.getCount();

      for (int block=blocks.length - 1; block>=0; block--) {
        blocks[block] = blocks[block].toLowerCase();
        blocks[block] =
          blocks[block].substring(blocks[block].lastIndexOf("_") + 1);

        if (blocks[block].equals("z")) {
          zCount[j] = count[block];
          order[j] += 'Z';
        }
        else if (blocks[block].equals("t")) {
          tCount[j] = count[block];
          order[j] += 'T';
        }
        else if (blocks[block].equals("mode")) {
          cCount[j] = count[block];
          order[j] += 'C';
        }
        else if (blocks[block].equals("im")) tileRows = count[block];
        else if (blocks[block].equals("")) tileCols = count[block];
        else if (blocks[block].replaceAll("\\d", "").length() == 0) {
          if (block == 3) tileRows = count[block];
          else if (block == 2) tileCols = count[block];
          else if (block == 0) {
            zCount[j] = count[block];
            order[j] += 'Z';
          }
          else if (block == 1) {
            tCount[j] = count[block];
            order[j] += 'T';
          }
        }
        else {
          throw new FormatException("Unsupported block '" + blocks[block]);
        }
      }

      Arrays.sort(tiffFiles);
      tiffs[j] = tiffFiles;
      LOGGER.debug("Well {} has {} files.", j, tiffFiles.length);
      readers[j] = new MinimalTiffReader[tiffFiles.length];
      for (int k=0; k<tiffFiles.length; k++) {
        readers[j][k] = new MinimalTiffReader();
      }
    }

    // Populate core metadata

    LOGGER.info("Populating core metadata");

    int nSeries = tiffs.length;

    bpp = new int[nSeries];

    if (readers.length == 0) {
      throw new FormatException("No wells were found.");
    }

    // assume that all wells have the same width, height, and pixel type
    readers[0][0].setId(tiffs[0][0]);
    tileWidth = readers[0][0].getSizeX();
    tileHeight = readers[0][0].getSizeY();

    if (tileCols == 0) tileCols = 1;
    if (tileRows == 0) tileRows = 1;

    core.clear();
    for (int i=0; i<nSeries; i++) {
      CoreMetadata ms = new CoreMetadata();
      core.add(ms);

      ms.sizeZ = zCount[i];
      ms.sizeC = cCount[i];
      ms.sizeT = tCount[i];

      if (ms.sizeZ == 0) ms.sizeZ = 1;
      if (ms.sizeC == 0) ms.sizeC = 1;
      if (ms.sizeT == 0) ms.sizeT = 1;

      ms.sizeX = tileWidth * tileCols;
      ms.sizeY = tileHeight * tileRows;
      ms.pixelType = readers[0][0].getPixelType();
      ms.sizeC *= readers[0][0].getSizeC();
      ms.rgb = readers[0][0].isRGB();
      ms.littleEndian = readers[0][0].isLittleEndian();
      ms.interleaved = readers[0][0].isInterleaved();
      ms.indexed = readers[0][0].isIndexed();
      ms.falseColor = readers[0][0].isFalseColor();
      ms.dimensionOrder = order[i];

      if (ms.dimensionOrder.indexOf('Z') == -1) {
        ms.dimensionOrder += 'Z';
      }
      if (ms.dimensionOrder.indexOf('C') == -1) {
        ms.dimensionOrder += 'C';
      }
      if (ms.dimensionOrder.indexOf('T') == -1) {
        ms.dimensionOrder += 'T';
      }

      ms.imageCount = ms.sizeZ * ms.sizeT * cCount[i];
      if (ms.imageCount == 0) {
        ms.imageCount = 1;
      }
      bpp[i] = FormatTools.getBytesPerPixel(ms.pixelType);
    }

    // Populate metadata hashtable

    LOGGER.info("Populating metadata hashtable");

    if (resultFile != null &&
      getMetadataOptions().getMetadataLevel() != MetadataLevel.MINIMUM)
    {
      String[] cols = null;
      final List<String> rows = new ArrayList<String>();

      boolean doKeyValue = true;
      int nStarLines = 0;

      String analysisResults = DataTools.readFile(resultFile);
      String[] lines = analysisResults.split("\n");

      for (String line : lines) {
        line = line.trim();
        if (line.length() == 0) continue;
        if (line.startsWith("******") && line.endsWith("******")) nStarLines++;

        if (doKeyValue) {
          String[] n = line.split("\t");
          if (n[0].endsWith(":")) n[0] = n[0].substring(0, n[0].length() - 1);
          if (n.length >= 2) addGlobalMeta(n[0], n[1]);
        }
        else {
          if (cols == null) cols = line.split("\t");
          else rows.add(line);
        }
        if (nStarLines == 2) doKeyValue = false;
      }

      for (String row : rows) {
        String[] d = row.split("\t");
        for (int col=3; col<cols.length; col++) {
          addGlobalMeta("Plate " + d[0] + ", Well " + d[2] + " " + cols[col],
            d[col]);

          if (cols[col].equals("AreaCode")) {
            String wellID = d[col].replaceAll("\\D", "");
            wellColumns = Integer.parseInt(wellID);
          }
        }
      }
    }

    // Populate MetadataStore

    LOGGER.info("Populating MetadataStore");

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

    // HACK: if we don't have the analysis file, we don't how many
    // rows/columns are in the plate
    //
    // assume that a 96 well plate is 8x12, and a 384 well plate is 16x24
    if (wellColumns == 0) {
      if (nWells == 96) {
        wellColumns = 12;
      }
      else if (nWells == 384) {
        wellColumns = 24;
      }
      else {
        LOGGER.warn("Could not determine the plate dimensions.");
        wellColumns = 24;
      }
    }

    store.setPlateID(MetadataTools.createLSID("Plate", 0), 0);

    String plateAcqId = MetadataTools.createLSID("PlateAcquisition", 0, 0);
    store.setPlateAcquisitionID(plateAcqId, 0, 0);
    store.setPlateAcquisitionMaximumFieldCount(new PositiveInteger(1), 0, 0);
    if (nWells >= wellColumns) {
      store.setPlateRows(new PositiveInteger(nWells / wellColumns), 0);
    }
    else {
      store.setPlateRows(new PositiveInteger(1), 0);
    }
    store.setPlateColumns(new PositiveInteger(wellColumns), 0);

    for (int well=0; well<nWells; well++) {
      int wellIndex = wellNumber[well];

      int row = wellIndex / wellColumns;
      int wellCol = (wellIndex % wellColumns) + 1;
      String wellRow = FormatTools.getWellRowName(row);

      store.setWellID(MetadataTools.createLSID("Well", 0, well), 0, well);
      store.setWellRow(new NonNegativeInteger(row), 0, well);
      store.setWellColumn(new NonNegativeInteger(wellCol - 1), 0, well);

      String imageID = MetadataTools.createLSID("Image", well);
      String wellSampleID = MetadataTools.createLSID("WellSample", 0, well, 0);
      store.setWellSampleID(wellSampleID, 0, well, 0);
      store.setWellSampleIndex(new NonNegativeInteger(well), 0, well, 0);

      store.setImageID(imageID, well);
      store.setImageName("Well " + wellRow + wellCol, well);
      store.setWellSampleImageRef(imageID, 0, well, 0);

      store.setPlateAcquisitionWellSampleRef(wellSampleID, 0, 0, well);
    }

    MetadataLevel level = getMetadataOptions().getMetadataLevel();

    if (level != MetadataLevel.MINIMUM) {
      String experimentID = MetadataTools.createLSID("Experiment", 0);
      store.setExperimentID(experimentID, 0);
      store.setExperimentType(MetadataTools.getExperimentType("Other"), 0);
      store.setExperimentDescription(experiment.getName(), 0);

      // populate SPW metadata
      store.setPlateColumnNamingConvention(MetadataTools.getNamingConvention("Number"), 0);
      store.setPlateRowNamingConvention(MetadataTools.getNamingConvention("Letter"), 0);

      parseTemplateFile(store);

      plateName = plateName.substring(plateName.indexOf('-') + 1);
      store.setPlateName(plateName, 0);
      store.setPlateExternalIdentifier(plateName, 0);

      for (int well=0; well<nWells; well++) {
        // populate Image/Pixels metadata
        store.setImageExperimentRef(experimentID, well);

        String instrumentID = MetadataTools.createLSID("Instrument", 0);
        store.setInstrumentID(instrumentID, 0);
        store.setImageInstrumentRef(instrumentID, well);
      }

      roiFiles = new ArrayList<AnalysisFile>();
      for (AnalysisFile af : analysisFiles) {
        String file = af.filename;
        String name = new Location(file).getName();
        if (!name.startsWith("Well")) continue;

        if (name.endsWith("AllModesOverlay.tif")) {
          roiFiles.add(af);
        }
        else if (name.endsWith("overlay.tif")) {
          roiFiles.add(af);
        }
      }

      if (level != MetadataLevel.NO_OVERLAYS) {
        // populate image-level ROIs
        Color[] colors = new Color[getSizeC()];

        int nextROI = 0;
        for (AnalysisFile af : analysisFiles) {
          String file = af.filename;
          String name = new Location(file).getName();
          if (!name.startsWith("Well")) continue;

          int[] position = getPositionFromFile(file);
          int well = position[0];

          if (name.endsWith("detail.txt")) {
            String data = DataTools.readFile(file);
            String[] lines = data.split("\n");
            int start = 0;
            while (start < lines.length && !lines[start].startsWith("Label")) {
              start++;
            }
            if (start >= lines.length) continue;

            String[] columns = lines[start].split("\t");
            List<String> columnNames = Arrays.asList(columns);

            for (int j=start+1; j<lines.length; j++) {
              populateROI(columnNames, lines[j].split("\t"), well,
                nextROI++, position[1], position[2], store);
            }
          }
          else if (name.endsWith("AllModesOverlay.tif")) {
            // original color for each channel is stored in
            // results/Well<nnnn>_mode<n>_z<nnn>_t<nnn>_AllModesOverlay.tif
            if (colors[position[3]] != null) continue;
            try {
              colors[position[3]] = getChannelColorFromFile(file);
            }
            catch (IOException e) { }
            if (colors[position[3]] == null) continue;

            for (int s=0; s<getSeriesCount(); s++) {
              store.setChannelColor(colors[position[3]], s, position[3]);
            }
            if (position[3] == 0) {
              nextROI += parseMasks(store, well, nextROI, file);
            }
          }
          else if (name.endsWith("overlay.tif")) {
            nextROI += parseMasks(store, well, nextROI, file);
          }
        }
      }
    }
  }

  // -- Helper methods --

  /**
   * Get the color associated with the given file's channel.
   * The file must be one of the
   * Well<nnnn>_mode<n>_z<nnn>_t<nnn>_AllModesOverlay.tif
   * files in <experiment>/<plate>/results/
   */
  private Color getChannelColorFromFile(String file)
    throws FormatException, IOException
  {
    IFD ifd = null;
    int[] colorMap = null;
    try (RandomAccessInputStream s = new RandomAccessInputStream(file, 16)) {
      TiffParser tp = new TiffParser(s);
      ifd = tp.getFirstIFD();
      if (ifd != null) {
        colorMap = tp.getColorMap(ifd);
      }
    }

    if (ifd == null) return null;
    if (colorMap == null) return null;

    int nEntries = colorMap.length / 3;
    int max = Integer.MIN_VALUE;
    int maxIndex = -1;
    for (int c=0; c<3; c++) {
      int v = (colorMap[c * nEntries] >> 8) & 0xff;
      if (v > max) {
        max = v;
        maxIndex = c;
      }
      else if (v == max) {
        return new Color(0, 0, 0, 255);
      }
    }

    switch (maxIndex) {
      case 0: // red
        return new Color(255, 0, 0, 255);
      case 1: // green
        return new Color(0, 255, 0, 255);
      case 2: // blue
        return new Color(0, 0, 255, 255);
    }
    return null;
  }

  /**
   * Returns an array of length 5 that contains the well, time point,
   * Z and channel indices corresponding to the given analysis file.
   */
  private int[] getPositionFromFile(String file) {
    int[] position = new int[4];

    file = file.substring(file.lastIndexOf(File.separator) + 1);
    String wellIndex = file.substring(4, file.indexOf('_'));
    position[0] = Integer.parseInt(wellIndex) - 1;

    int tIndex = file.indexOf("_t") + 2;
    String t = file.substring(tIndex, file.indexOf("_", tIndex));
    position[1] = Integer.parseInt(t);

    int zIndex = file.indexOf("_z") + 2;
    String zValue = file.substring(zIndex, file.indexOf("_", zIndex));
    position[2] = Integer.parseInt(zValue);

    int cIndex = file.indexOf("mode") + 4;
    String cValue = file.substring(cIndex, file.indexOf("_", cIndex));
    position[3] = Integer.parseInt(cValue) - 1;

    return position;
  }

  private void populateROI(List<String> columns, String[] data, int series,
    int roi, int time, int z, MetadataStore store)
  {
    String roiID = MetadataTools.createLSID("ROI", roi, 0);
    store.setROIID(roiID, roi);
    store.setImageROIRef(roiID, series, roi);

    store.setEllipseID(MetadataTools.createLSID("Shape", roi, 0), roi, 0);
    store.setEllipseTheT(new NonNegativeInteger(time), roi, 0);
    store.setEllipseTheZ(new NonNegativeInteger(z), roi, 0);
    store.setEllipseX(DataTools.parseDouble(data[columns.indexOf("Col")]), roi, 0);
    store.setEllipseY(DataTools.parseDouble(data[columns.indexOf("Row")]), roi, 0);
    store.setEllipseText(data[columns.indexOf("Label")], roi, 0);

    double diam = Double.parseDouble(data[columns.indexOf("Cell Diam.")]);
    double radius = diam / 2;

    store.setEllipseRadiusX(radius, roi, 0);
    store.setEllipseRadiusY(radius, roi, 0);

    // NB: other attributes are "Nucleus Area", "Cell Type", and
    // "Mean Nucleus Intens."
  }

  private byte[] getTile(int well, int no, int row, int col,
    Region intersection) throws FormatException, IOException
  {
    intersection.x %= tileWidth;
    intersection.y %= tileHeight;

    int tileIndex = (no * tileRows + row) * tileCols + col;

    readers[well][tileIndex].setId(tiffs[well][tileIndex]);
    int bpp = FormatTools.getBytesPerPixel(getPixelType());
    int ch = getRGBChannelCount();
    int bufferSize = intersection.width * intersection.height * ch * bpp;
    if (cachedTileBuffer == null || cachedTileBuffer.length != bufferSize) {
      cachedTileBuffer = new byte[bufferSize];
    }
    byte[] buf = readers[well][tileIndex].openBytes(0, cachedTileBuffer,
      intersection.x, intersection.y, intersection.width, intersection.height);
    readers[well][tileIndex].close();
    return buf;
  }

  /** Parse metadata from the Nugenesistemplate.txt file. */
  private void parseTemplateFile(MetadataStore store) throws IOException {
    if (templateFile == null) return;

    Double physicalSizeX = null, physicalSizeY = null, exposure = null;
    final List<String> channelNames = new ArrayList<String>();
    String date = null;

    String data = DataTools.readFile(templateFile);
    String[] lines = data.split("\r\n");

    for (String line : lines) {
      int eq = line.indexOf('=');
      if (eq != -1) {
        String key = line.substring(0, eq);
        String value = line.substring(eq + 1);

        if (key.equals("Barcode")) {
          store.setPlateExternalIdentifier(value, 0);
        }
        else if (key.equals("Carrier")) {
          store.setPlateName(value, 0);
        }
        else if (key.equals("Pixel_X")) {
          physicalSizeX = DataTools.parseDouble(value);
        }
        else if (key.equals("Pixel_Y")) {
          physicalSizeY = DataTools.parseDouble(value);
        }
        else if (key.equals("Objective_ID")) {
          store.setObjectiveID(
            MetadataTools.createLSID("Objective", 0, 0), 0, 0);
          store.setObjectiveModel(value, 0, 0);
        }
        else if (key.equals("Magnification")) {
          Double mag = Double.parseDouble(value);
          store.setObjectiveNominalMagnification(mag, 0, 0);
        }
        else if (key.startsWith("Mode_")) {
          channelNames.add(value);
        }
        else if (key.equals("Date")) {
          date = value;
        }
        else if (key.equals("Time")) {
          date += " " + value;
        }
        else if (key.equals("Exposure")) {
          exposure = DataTools.parseDouble(value);
        }
      }
    }

    for (int well=0; well<tiffs.length; well++) {
      Length sizeX = FormatTools.getPhysicalSizeX(physicalSizeX);
      Length sizeY = FormatTools.getPhysicalSizeY(physicalSizeY);

      if (sizeX != null) {
        store.setPixelsPhysicalSizeX(sizeX, well);
      }
      if (sizeY != null) {
        store.setPixelsPhysicalSizeY(sizeY, well);
      }
      for (int c=0; c<channelNames.size(); c++) {
        if (c < getEffectiveSizeC()) {
          store.setChannelName(channelNames.get(c), well, c);
        }
      }
      date = DateTools.formatDate(date, "dd/MM/yyyy HH:mm:ss");
      if (date != null) {
        store.setImageAcquisitionDate(new Timestamp(date), well);
      }

      if (exposure != null) {
        for (int i=0; i<getImageCount(); i++) {
          store.setPlaneExposureTime(new Time(exposure, UNITS.SECOND), well, i);
        }
      }
    }
  }

  /**
   * Parse masks into a separate overlay-specific MetadataStore.
   */
  public void parseMasks(MetadataStore overlayStore)
    throws FormatException, IOException
  {
    boolean originalMaskParsing = parseMasks;
    int roi = 0;
    parseMasks = true;
    for (AnalysisFile roiFile : roiFiles) {
      roi += parseMasks(overlayStore, roiFile.well, roi, roiFile.filename);
    }
    parseMasks = originalMaskParsing;
  }

  /**
   * Parse Mask ROIs from the given TIFF and place them in the given
   * MetadataStore.
   * @return the number of masks parsed
   */
  private int parseMasks(MetadataStore store, int series, int roi,
    String overlayTiff) throws FormatException, IOException
  {
    if (!parseMasks || series >= getSeriesCount()) return 0;
    int nOverlays = 0;
    for (int i=0; i<3; i++) {
      String roiId = MetadataTools.createLSID("ROI", series, roi + nOverlays);
      String maskId =
        MetadataTools.createLSID("Mask", series, roi + nOverlays, 0);
      overlayFiles.put(maskId, overlayTiff);
      overlayPlanes.put(maskId, i);

      boolean validMask = populateMaskPixels(series, roi + nOverlays, 0, store);
      if (validMask) {
        store.setROIID(roiId, roi + nOverlays);

        String maskID = MetadataTools.createLSID("Shape", roi + nOverlays, 0);
        store.setMaskID(maskID, roi + nOverlays, 0);
        store.setMaskX(0d, roi + nOverlays, 0);
        store.setMaskY(0d, roi + nOverlays, 0);
        store.setMaskWidth((double) getSizeX(), roi + nOverlays, 0);
        store.setMaskHeight((double) getSizeY(), roi + nOverlays, 0);

        int color = 0xff000000 | (0xff << (8 * (2 - i)));
        store.setMaskStrokeColor(new Color(color), roi + nOverlays, 0);
        store.setMaskFillColor(new Color(color), roi + nOverlays, 0);
        store.setImageROIRef(roiId, series, roi + nOverlays);
        nOverlays++;
      }
    }
    return nOverlays;
  }

  // -- MIASReader API methods --

  /**
   * Populate the MaskPixels.BinData attribute for the Mask identified by the
   * given Image index, ROI index, and Shape index.
   * @return true if the mask was populated successfully.
   */
  public boolean populateMaskPixels(int imageIndex, int roiIndex,
    int shapeIndex, MetadataStore store)
    throws FormatException, IOException
  {
    FormatTools.assertId(currentId, true, 1);

    String id =
      MetadataTools.createLSID("Mask", imageIndex, roiIndex, shapeIndex);
    String maskFile = overlayFiles.get(id);
    if (maskFile == null) {
      LOGGER.warn("Could not find an overlay file matching {}", id);
      return false;
    }

    MinimalTiffReader r = new MinimalTiffReader();
    r.setId(maskFile);

    int index = overlayPlanes.get(id).intValue();
    byte[] plane = r.openBytes(0);
    byte[][] planes = null;

    if (r.isIndexed()) {
      planes = ImageTools.indexedToRGB(r.get8BitLookupTable(), plane);
    }
    else {
      int bpp = FormatTools.getBytesPerPixel(r.getPixelType());
      planes = new byte[r.getRGBChannelCount()][];
      for (int c=0; c<planes.length; c++) {
        planes[c] = ImageTools.splitChannels(plane, c, r.getRGBChannelCount(),
          bpp, false, r.isInterleaved());
      }
    }
    r.close();

    for (int i=0; i<planes[0].length; i++) {
      boolean channelsEqual = true;
      for (int c=1; c<planes.length; c++) {
        if (planes[c][i] != planes[0][i]) {
          channelsEqual = false;
          break;
        }
      }
      if (channelsEqual) {
        for (int c=0; c<planes.length; c++) {
          planes[c][i] = 0;
        }
      }
    }

    // threshold and binary encode the pixel data

    boolean validMask = false;
    BitWriter bits = null;
    if (planes.length > index) {
      bits = new BitWriter(planes[index].length / 8);
      for (int p=0; p<planes[index].length; p++) {
        bits.write(planes[index][p] == 0 ? 0 : 1, 1);
        if (planes[index][p] != 0) {
          validMask = true;
        }
      }
    }

    if (validMask) {
      store.setMaskBinData(bits.toByteArray(), roiIndex, shapeIndex);
    }
    else LOGGER.debug("Did not populate MaskPixels.BinData for {}", id);

    return validMask;
  }

  /**
   * Toggle whether or not Mask pixel data should be parsed in setId.
   * By default, it is not parsed.
   */
  public void setAutomaticallyParseMasks(boolean parse) throws FormatException {
    FormatTools.assertId(currentId, false, 1);
    this.parseMasks = parse;
  }

  // -- Helper class --

  class AnalysisFile {
    public String filename;
    public int plate = -1, well = -1;
  }

}
