package com.srbenoit.filter.items;

import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.text.ParseException;
import java.util.List;
import java.util.logging.Level;
import javax.imageio.ImageIO;
import com.srbenoit.filter.AbstractPipeItem;
import com.srbenoit.filter.FilterTreeExecutor;
import com.srbenoit.filter.Pipe;
import com.srbenoit.filter.PipeItemFileInfo;
import com.srbenoit.xml.ElementBase;
import com.srbenoit.xml.EmptyElement;
import com.srbenoit.xml.Node;
import com.srbenoit.xml.XmlParser;

/**
 * An array of images that are backed by files. This class includes methods to create such an image
 * and retrieve the <code>BufferedImage</code>, to load it from its backing file (if present), and
 * write it to its backing file.
 *
 * <p>We maintain an array of file information objects in parallel with the image objects. While
 * the image may or may not be loaded at any given time, the presence of a file information object
 * with persisted = true indicates that there is a file with the image data and we can load the
 * file.
 */
public class ImageArrayPipeItem extends AbstractPipeItem {

    /** format string for JPEG files */
    public static final String FORMAT_JPEG = "JPEG";

    /** format string for PNG files */
    public static final String FORMAT_PNG = "PNG";

    /** format string for TIFF files */
    public static final String FORMAT_TIFF = "TIFF";

    /** the image (either created by this class, or loaded from the file) */
    private transient BufferedImage[][] images;

    /** the file info for each image file */
    private transient PipeItemFileInfo[][] fileInfo;

    /** the label for the X axis (typically "x" or "t") */
    private transient String xLabel;

    /** the label for the Y axis (typically "y" or "z") */
    private transient String yLabel;

    /** the file suffix for the image file format */
    private transient String type;

    /**
     * Constructs a new empty <code>ImageArrayPipeItem</code>.
     *
     * @param  theKey    the unique key for the item
     * @param  theLabel  the label for the item (a human friendly name)
     * @param  thePipe   the pipe in which this item is installed
     */
    public ImageArrayPipeItem(final String theKey, final String theLabel, final Pipe thePipe) {

        super(theKey, theLabel, thePipe);

        File file;

        this.images = new BufferedImage[0][0];
        this.fileInfo = new PipeItemFileInfo[0][0];
        this.xLabel = "";
        this.yLabel = "";
        this.type = "";

        ImageIO.scanForPlugins();

        file = makeFile(getSubdir());
        addFile(new PipeItemFileInfo(file));
    }

    /**
     * Constructs a new <code>ImageArrayPipeItem</code> with a newly allocated <code>
     * BufferedImage</code> of a given size and type. The new (blank) image is not written to the
     * file.
     *
     * @param  theKey      the unique key for the item
     * @param  theLabel    the label for the item (a human friendly name)
     * @param  thePipe     the pipe in which this item is installed
     * @param  theXLabel   the label for the X axis (typically "x" or "t")
     * @param  theYLabel   the label for the Y axis (typically "y" or "z")
     * @param  numX        the number of images in the X (or time) direction
     * @param  numY        the number if images in the Y (or plane) direction
     * @param  fileSuffix  the file suffix for images written to disk (determines format)
     */
    public ImageArrayPipeItem(final String theKey, final String theLabel, final Pipe thePipe,
        final String theXLabel, final String theYLabel, final int numX, final int numY,
        final String fileSuffix) {

        super(theKey, theLabel, thePipe);

        File file;

        if (numX < 1) {
            throw new IllegalArgumentException("Number of time points be at least 1");
        }

        if (numY < 1) {
            throw new IllegalArgumentException("Number of planes be at least 1");
        }

        if (fileSuffix == null) {
            throw new IllegalArgumentException("File suffix may not be null");
        }

        this.images = new BufferedImage[numX][numY];
        this.fileInfo = new PipeItemFileInfo[numX][numY];
        this.xLabel = theXLabel;
        this.yLabel = theYLabel;

        setType(fileSuffix);

        file = makeFile(getSubdir());
        addFile(new PipeItemFileInfo(file));
    }

    /**
     * Sets the file type to which images should be saved.
     *
     * @param   type  the file type (extension)
     * @throws  IllegalArgumentException  if the type is not valid
     */
    private void setType(final String type) throws IllegalArgumentException {

        boolean found;
        String[] suffixes;
        StringBuilder str;

        ImageIO.scanForPlugins();
        found = false;
        suffixes = ImageIO.getReaderFileSuffixes();

        for (String test : suffixes) {

            if (test.equals(type)) {
                found = true;

                break;
            }
        }

        if (!found) {
            str = new StringBuilder(80);
            str.append("Invalid image file suffix '");
            str.append(type);
            str.append("' (supported suffixes are");

            for (String test : suffixes) {
                str.append(' ');
                str.append(test);
            }

            throw new IllegalArgumentException(str.toString());
        }

        this.type = type;
    }

    /**
     * Sets the image at a particular X and Y position.
     *
     * @param  xPos  the X position
     * @param  yPos  the Y position
     * @param  img   the image
     */
    public void setImage(final int xPos, final int yPos, final BufferedImage img) {

        File sub;
        File file;

        this.images[xPos][yPos] = img;

        if (this.fileInfo[xPos][yPos] != null) {
            removeFile(this.fileInfo[xPos][yPos]);
        }

        sub = new File(getPipe().getDir(), getKey());
        file = makeImageFile(sub, xPos, yPos);
        this.fileInfo[xPos][yPos] = new PipeItemFileInfo(file);
        addFile(this.fileInfo[xPos][yPos]);
    }

    /**
     * Sets the file suffix for files written by this pipe.
     *
     * @param  fileSuffix  the file suffix for images written to disk (determines format)
     */
    public void setFileSuffix(final String fileSuffix) {

        File sub;

        if (fileSuffix == null) {
            throw new IllegalArgumentException("File suffix may not be null");
        }

        if (!this.type.equals(fileSuffix)) {
            this.type = fileSuffix;
            getFile(0).notPersisted();

            // We need to rebuild all file info objects and mark everything as not persisted
            sub = getSubdir();

            for (int x = 0; x < this.fileInfo.length; x++) {

                for (int y = 0; y < this.fileInfo[x].length; y++) {

                    if (this.fileInfo[x][y] != null) {
                        this.fileInfo[x][y].setFile(makeImageFile(sub, x, y));
                        this.fileInfo[x][y].notPersisted();
                    }
                }
            }
        }
    }

    /**
     * Gets the size of the image array along the X direction.
     *
     * @return  the array X dimension
     */
    public int getXSize() {

        return this.images.length;
    }

    /**
     * Gets the size of the image array along the Y direction.
     *
     * @return  the array Y dimension
     */
    public int getYSize() {

        return this.images[0].length;
    }

    /**
     * Gets the label for the X dimension.
     *
     * @return  the label
     */
    public String getXLabel() {

        return this.xLabel;
    }

    /**
     * Gets the label for the Y dimension.
     *
     * @return  the label
     */
    public String getYLabel() {

        return this.yLabel;
    }

    /**
     * Gets the image at a particular X and Y position. If the image is cached but has not been
     * loaded, this loads the image then returns it.
     *
     * @param   xPos  the X position
     * @param   yPos  the Y position
     * @return  the image, or <code>null</code> if no image has been set
     */
    public BufferedImage getImage(final int xPos, final int yPos) {

        File sub;
        File file;

        sub = getSubdir();

        if (this.images[xPos][yPos] == null) {
            file = makeImageFile(sub, xPos, yPos);

            if (file.exists()) {
                this.images[xPos][yPos] = loadImage(file);
            }
        }

        return this.images[xPos][yPos];
    }

    /**
     * Gets the set of images at a particular X position. If any image in the set is cached but has
     * not been loaded, this loads the image before returning the array.
     *
     * @param   xPos  the X position
     * @return  the images, any of which may be <code>null</code>
     */
    public BufferedImage[] getImages(final int xPos) {

        File sub;
        File file;

        sub = getSubdir();

        for (int y = 0; y < this.images[xPos].length; y++) {

            if (this.images[xPos][y] == null) {
                file = makeImageFile(sub, xPos, y);

                if (file.exists()) {
                    this.images[xPos][y] = loadImage(file);
                }
            }
        }

        return this.images[xPos];
    }

    /**
     * Loads an image from the filesystem.
     *
     * @param   file  the file to load
     * @return  the loaded image
     */
    private BufferedImage loadImage(final File file) {

        BufferedImage img;

        try {
            img = ImageIO.read(file);
        } catch (IOException e) {
            LOG.log(Level.WARNING, "Unable to load image " + file.getAbsolutePath(), e);
            img = null;
        }

        return img;
    }

    /**
     * Gets a human-friendly name for the data type. For example, a list of sets of images
     * representing a time series of z-planes might return "Multi-plane image sequence".
     *
     * @return  the name of the data type this item represents
     */
    @Override public String typeName() {

        return "Image Array";
    }

    /**
     * Resets the pipe item to a virgin (empty) state.
     */
    @Override public void reset() {

        for (int x = 0; x < this.images.length; x++) {

            for (int y = 0; y < this.images[x].length; y++) {
                this.images[x][y] = null;
                this.fileInfo[x][y] = null;
            }
        }

        removeAllFilesButFirst();

        getFile(0).notPersisted();
    }

    /**
     * Saves the item to a filesystem.
     *
     * @param   executor  the executor that is saving the pipe
     * @param   startPct  the starting progress percentage for the save operation
     * @param   endPct    the ending progress percentage for the save operation
     * @return  <code>true</code> if the save succeeded; <code>false</code> if not
     */
    @Override public boolean save(final FilterTreeExecutor executor, final int startPct,
        final int endPct) {

        int total;
        int soFar;
        File sub;
        StringBuilder str;
        boolean result;
        PipeItemFileInfo info;

        sub = getSubdir();

        if (!sub.exists()) {
            sub.mkdir();
        }

        // Count the number of files we need to write
        total = 0;

        for (int x = 0; x < this.images.length; x++) {

            for (int y = 0; y < this.images[x].length; y++) {

                info = this.fileInfo[x][y];

                if ((info != null) && (!info.isPersisted())) {
                    total++;
                }
            }
        }

        result = true;
        soFar = 0;

        for (int x = 0; x < this.images.length; x++) {

            for (int y = 0; y < this.images[x].length; y++) {

                info = this.fileInfo[x][y];

                if ((info != null) && (!info.isPersisted())) {

                    soFar++;
                    executor.indicateProgress(startPct + (soFar * (endPct - startPct) / total));
                    result = persistImage(sub, x, y);

                    if (result) {
                        info.wasPersisted();
                    } else {
                        break;
                    }
                }
            }
        }

        if (result) {
            executor.indicateProgress(endPct);

            info = getFile(0);
            str = new StringBuilder(500);
            str.append("<image-array x-len='");
            str.append(Integer.toString(this.images.length));
            str.append("' x-lbl='");
            str.append(ElementBase.encode(this.xLabel));
            str.append("' y-len='");
            str.append(Integer.toString(this.images[0].length));
            str.append("' y-lbl='");
            str.append(ElementBase.encode(this.yLabel));
            str.append("' type='");
            str.append(ElementBase.encode(this.type));
            str.append("'/>");

            result = Pipe.writeFile(info.getFile(), str.toString().getBytes());

            if (result) {
                info.wasPersisted();
            } else {
                info.notPersisted();
            }
        }

        return result;
    }

    /**
     * Writes a single image file to the filesystem.
     *
     * @param   sub   the directory to which to save the item's image files
     * @param   xPos  the X index of the image to write
     * @param   yPos  the Y index of the image to write
     * @return  <code>true</code> if all non-null images were written to the disk successfully;
     *          <code>false</code> if not
     */
    private boolean persistImage(final File sub, final int xPos, final int yPos) {

        File file;
        boolean result;

        file = makeImageFile(sub, xPos, yPos);

        try {
            ImageIO.write(this.images[xPos][yPos], this.type, file);
            result = true;
        } catch (IOException e) {
            LOG.log(Level.WARNING, "Exception writing image file", e);
            result = false;
        }

        return result;
    }

    /**
     * Loads the items from the filesystem
     *
     * @return  <code>true</code> if the load succeeded; <code>false</code> if not
     */
    @Override public boolean load() {

        File sub;
        File file;
        byte[] bytes;
        List<Node> nodes;
        EmptyElement empty;
        String xLen;
        String yLen;
        int width;
        int height;
        PipeItemFileInfo info;
        boolean result;

        reset();

        sub = getSubdir();
        info = getFile(0);

        bytes = Pipe.readFile(info.getFile());

        if (bytes == null) {
            LOG.log(Level.INFO, "Unable to read image array file: {0}",
                info.getFile().getAbsolutePath());
            info.notPersisted();
            result = false;
        } else {

            try {
                nodes = new XmlParser().parse(new String(bytes), true);

                if ((nodes.size() == 1) && (nodes.get(0) instanceof EmptyElement)) {
                    empty = (EmptyElement) nodes.get(0);
                    xLen = empty.get("x-len");
                    yLen = empty.get("y-len");
                    this.xLabel = empty.get("x-lbl");
                    this.yLabel = empty.get("y-lbl");

                    if ((this.xLabel == null) || (this.yLabel == null)) {
                        LOG.warning("Missing x-lbl or y-lbl in ImageArray xml file");
                        result = false;
                    } else {
                        setType(empty.get("type"));

                        try {
                            width = Integer.parseInt(xLen);
                            height = Integer.parseInt(yLen);

                            if ((width != this.images.length)
                                    || (height != this.images[0].length)) {
                                this.images = new BufferedImage[width][height];
                                this.fileInfo = new PipeItemFileInfo[width][height];
                            }

                            info.wasPersisted();

                            result = true;

                        } catch (NumberFormatException e) {
                            LOG.warning("Invalid length while loading ImageArray");
                            result = false;
                        }
                    }
                } else {
                    LOG.warning("Unable to parse ImageArray xml file");
                    result = false;
                }
            } catch (ParseException e) {
                LOG.warning("Unable to parse ImageArray xml file");
                result = false;
            }
        }

        if (result) {

outer:
            for (int x = 0; x < this.images.length; x++) {

                for (int y = 0; y < this.images[x].length; y++) {
                    file = makeImageFile(sub, x, y);

                    if (file.exists()) {
                        info = new PipeItemFileInfo(file); // NOPMD SRB
                        info.wasPersisted();
                        addFile(info);
                    }
                }
            }
        }

        return result;
    }

    /**
     * Creates a file that represents the object data.
     *
     * @param   key   the key associated with the item
     * @param   pipe  the pipe in which this item is being loaded/saved
     * @return  the file
     */
    private File makeFile(final File dir) {

        return new File(dir, getKey() + "_ImageArray.xml");
    }

    /**
     * Creates a file that will be used to store one image.
     *
     * @param   key     the key associated with the item
     * @param   pipe    the pipe in which this item is being loaded/saved
     * @param   xIndex  the X index of the image whose filename to construct
     * @param   yIndex  the Y index of the image whose filename to construct
     * @return  the file
     */
    private File makeImageFile(final File dir, final int xIndex, final int yIndex) {

        return new File(dir,
                getKey() + "_Image_" + this.xLabel + xIndex + this.yLabel + yIndex + "."
                + this.type);
    }
}
