package com.srbenoit.filter.items;

import java.io.File;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.logging.Level;
import com.srbenoit.filter.AbstractPipeItem;
import com.srbenoit.filter.FilterTreeExecutor;
import com.srbenoit.filter.Pipe;
import com.srbenoit.filter.PipeItemFileInfo;
import com.srbenoit.util.LocalTime;
import com.srbenoit.xml.ElementBase;
import com.srbenoit.xml.EmptyElement;
import com.srbenoit.xml.Node;
import com.srbenoit.xml.NonemptyElement;
import com.srbenoit.xml.XmlParser;

/**
 * An ordered series of points in time (specified as local date/times) , with the ability to
 * designate named subsequences.
 */
public class TimeSeriesPipeItem extends AbstractPipeItem {

    /** version number for serialization */
    private static final long serialVersionUID = -7300471956483249199L;

    /** zero-length String array for use in list to array conversion */
    private static final String[] STRING_0 = new String[0];

    /** zero-length local time array for use in list to array conversion */
    private static final LocalTime[] LOCALTIME_0 = new LocalTime[0];

    /** zero-length local time array for use in list to array conversion */
    private static final String VALUE_TAG = "value";

    /** end of line characters */
    private static final String CRLF;

    /** the list of times */
    private final transient List<LocalTime> times;

    /** the list of named subsequences */
    private final transient Map<String, List<LocalTime>> subsequences;

    static {
        String crlf;

        crlf = System.getProperty("line.separator");
        CRLF = (crlf == null) ? "\n" : crlf;
    }

    /**
     * Constructs a new <code>TimeSeriesPipeItem</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 TimeSeriesPipeItem(final String theKey, final String theLabel, final Pipe thePipe) {

        super(theKey, theLabel, thePipe);

        PipeItemFileInfo info;

        this.times = new ArrayList<LocalTime>(50);
        this.subsequences = new TreeMap<String, List<LocalTime>>();

        info = new PipeItemFileInfo(makeFile(getSubdir()));
        addFile(info);
    }

    /**
     * Adds a time point to the end of the series.
     *
     * @param  time  the time point to add
     */
    public void addTimePoint(final LocalTime time) {

        this.times.add(time);
        getFile(0).notPersisted();
    }

    /**
     * Adds a time point at a particular position in the series.
     *
     * @param  index  the position at which to add the time point
     * @param  time   the time point to add
     */
    public void addTimePoint(final int index, final LocalTime time) {

        this.times.add(index, time);
        getFile(0).notPersisted();
    }

    /**
     * Replaces the time point at the specified position in this time series with the specified
     * element, and also replaces it in any named subsequences of which the value being replaced
     * was a part.
     *
     * @param   index    index of the time point to replace
     * @param   element  time point to be stored at the specified position
     * @return  the time point previously at the specified position
     */
    public LocalTime setTimePoint(final int index, final LocalTime element) {

        LocalTime oldValue;
        List<LocalTime> list;

        oldValue = this.times.set(index, element);

        if (oldValue != null) {

            for (String key : this.subsequences.keySet()) {
                list = this.subsequences.get(key);

                if (list.remove(oldValue)) {
                    list.add(element);
                }
            }
        }

        getFile(0).notPersisted();

        return oldValue;
    }

    /**
     * Removes the time point of the time series at the specified position in this list, and
     * removes that element from any named subsequences of which it is a part.
     *
     * @param   index  the index of the time point to be removed
     * @return  the time point that was removed from the list
     */
    public LocalTime removeTimePoint(final int index) {

        LocalTime oldValue;

        oldValue = this.times.remove(index);

        if (oldValue != null) {

            for (String key : this.subsequences.keySet()) {
                this.subsequences.get(key).remove(oldValue);
            }

            getFile(0).notPersisted();
        }

        return oldValue;
    }

    /**
     * Removes the first occurrence of the specified time point from this time series, if it is
     * present, and also removes the time point from any named subsequence of which it is a part.
     *
     * @param   obj  time point to be removed from this list, if present
     * @return  <code>true</code> if this list contained the specified time point
     */
    public boolean removeTimePoint(final LocalTime obj) {

        boolean found;

        found = this.times.remove(obj);

        if (found) {

            for (String key : this.subsequences.keySet()) {
                this.subsequences.get(key).remove(obj);
            }

            getFile(0).notPersisted();
        }

        return found;
    }

    /**
     * Gets the number of time points in the series
     *
     * @return  the number of time points
     */
    public int getNumTimePoints() {

        return this.times.size();
    }

    /**
     * Get a particular time points from the series.
     *
     * @param   index  the index of the time point to get
     * @return  the time point
     */
    public LocalTime getTimePoint(final int index) {

        return this.times.get(index);
    }

    /**
     * Gets the current ordered list of time points in the series.
     *
     * @return  the list of time points
     */
    public LocalTime[] getTimePoints() {

        return this.times.toArray(LOCALTIME_0);
    }

    /**
     * Creates a new named subsequence.
     *
     * @param  name  the name of the subsequence
     */
    public void createSubsequence(final String name) {

        if (!this.subsequences.containsKey(name)) {
            this.subsequences.put(name, new ArrayList<LocalTime>(50));
            getFile(0).notPersisted();
        }
    }

    /**
     * Returns the list of names of subsequences.
     *
     * @return  the list of names
     */
    public String[] getSubsequencNames() {

        return this.subsequences.keySet().toArray(STRING_0);
    }

    /**
     * Adds a time to a subsequence.
     *
     * @param  name  the name of the subsequence
     * @param  time  the time to add
     */
    public void addToSubsequence(final String name, final LocalTime time) {

        List<LocalTime> list;

        list = this.subsequences.get(name);

        if (list == null) {
            throw new IllegalArgumentException("No subsequence was found named '" + name + "'");
        }

        if (!list.contains(time)) {
            list.add(time);
            getFile(0).notPersisted();
        }
    }

    /**
     * Gets the elements of a named subsequence in the order in which they appear in the time
     * series.
     *
     * @param   name  the name of the subsequence
     * @return  the elements in the subsequence, or <code>null</code> if there is no subsequence
     *          with the specified name
     */
    public LocalTime[] getSubsequenceTimes(final String name) {

        List<LocalTime> list;
        LocalTime[] array;
        int index;

        list = this.subsequences.get(name);

        if (list == null) {
            array = null;
        } else {
            array = new LocalTime[list.size()];
            index = 0;

            for (LocalTime test : this.times) {

                if (list.contains(test)) {
                    array[index] = test;
                    index++;
                }
            }
        }

        return array;
    }

    /**
     * Removes a time from a subsequence.
     *
     * @param  name  the name of the subsequence
     * @param  time  the time to remove
     */
    public void removeFromSubsequence(final String name, final LocalTime time) {

        List<LocalTime> list;

        list = this.subsequences.get(name);

        if (list == null) {
            throw new IllegalArgumentException("No subsequence was found named '" + name + "'");
        }

        if (list.remove(time)) {
            getFile(0).notPersisted();
        }
    }

    /**
     * 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 "Time Series";
    }

    /**
     * Resets the pipe item to a virgin (empty) state.
     */
    @Override public void reset() {

        this.times.clear();

        for (String key : this.subsequences.keySet()) {
            this.subsequences.get(key).clear();
        }

        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> on successful save; <code>false</code> on failure
     */
    @Override public boolean save(final FilterTreeExecutor executor, final int startPct,
        final int endPct) {

        StringBuilder str;
        List<LocalTime> seq;
        PipeItemFileInfo info;
        boolean result;

        executor.indicateProgress((startPct + endPct) / 2);

        if (this.times.isEmpty()) {
            result = false;
        } else {
            str = new StringBuilder(500);

            str.append("<time-series>");
            str.append(CRLF);

            // Write out the ordered list of time points
            for (LocalTime time : this.times) {
                str.append("  ");
                str.append("<time ");
                str.append(VALUE_TAG);
                str.append("='");
                str.append(time.toString());
                str.append("'/>");
                str.append(CRLF);
            }

            // Write out any subsequences
            for (String subseq : this.subsequences.keySet()) {
                seq = this.subsequences.get(subseq);
                str.append("  ");
                str.append("<subsequence name='");
                str.append(ElementBase.encode(subseq));
                str.append("'>");
                str.append(CRLF);

                for (LocalTime time : seq) {
                    str.append("    ");
                    str.append("<time ");
                    str.append(VALUE_TAG);
                    str.append("='");
                    str.append(time.toString());
                    str.append("<'/>");
                    str.append(CRLF);
                }

                str.append("  ");
                str.append("</subsequence>");
            }

            str.append("</time-series>");
            str.append(CRLF);

            info = getFile(0);

            if (Pipe.writeFile(info.getFile(), str.toString().getBytes())) {
                info.wasPersisted();
                result = true;
            } else {
                info.notPersisted();
                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() {

        PipeItemFileInfo info;
        byte[] data;
        XmlParser parser;
        List<Node> parsed;
        NonemptyElement top;
        boolean result;

        info = getFile(0);

        reset();

        data = Pipe.readFile(info.getFile());

        if (data == null) {
            LOG.log(Level.INFO, "Unable to read string file: {0}",
                info.getFile().getAbsolutePath());
            info.notPersisted();
            result = false;
        } else {
            parser = new XmlParser();

            try {
                parsed = parser.parse(new String(data), true);

                // Should be one node at top level, "time-series"
                if ((parsed != null) && (parsed.size() == 1)
                        && (parsed.get(0) instanceof NonemptyElement)) {

                    top = (NonemptyElement) parsed.get(0);

                    if ("time-series".equals(top.tagName)) {
                        result = processTopLevel(top);
                    } else {
                        LOG.warning("Missing <time-series> element");
                        result = false;
                    }
                } else {
                    LOG.warning("Missing <time-series> element");
                    result = false;
                }

            } catch (ParseException e) {
                LOG.log(Level.WARNING, "Exception while parsing XML", e);
                result = false;
            }

            if (result) {
                info.wasPersisted();
            } else {
                info.notPersisted();
            }
        }

        return result;
    }

    /**
     * Processes the top-level "time-series" node by verifying that all contained nodes are either
     * <time> or <sequence> nodes, and parsing them as they are found.
     *
     * @param   top  the top-level node to parse
     * @return  <code>true</code> if the node is valid; <code>false</code> if not
     */
    private boolean processTopLevel(final NonemptyElement top) {

        NonemptyElement nonempty;
        EmptyElement empty;
        boolean result;

        result = true;

        for (Node node : top.children) {

            if (node instanceof NonemptyElement) {
                nonempty = (NonemptyElement) node;

                if ("subsequence".equals(nonempty.tagName)) {
                    result = processSubsequence(nonempty);
                } else {
                    LOG.log(Level.WARNING, "Invalid element ''{0}'' within <time-series>",
                        nonempty.tagName);
                    result = false;

                    break;
                }
            } else if (node instanceof EmptyElement) {
                empty = (EmptyElement) node;

                if ("time".equals(empty.tagName)) {

                    try {
                        addTimePoint(LocalTime.parse(empty.get(VALUE_TAG)));
                    } catch (IllegalArgumentException e) {
                        LOG.log(Level.WARNING, "Invalid time point value ''{0}'' in <time>",
                            empty.get(VALUE_TAG));
                        result = false;

                        break;
                    }
                } else {
                    LOG.log(Level.WARNING, "Invalid element ''{0}'' within <time-series>",
                        empty.tagName);
                    result = false;

                    break;
                }
            }
        }

        return result;
    }

    /**
     * Processes a "subsequence" node by verifying that all contained nodes are <time> nodes, and
     * parsing them as they are found.
     *
     * @param   subseq  the subsequence node to parse
     * @return  <code>true</code> if the node is valid; <code>false</code> if not
     */
    private boolean processSubsequence(final NonemptyElement subseq) {

        String name;
        EmptyElement empty;
        boolean result;

        name = subseq.get("name");

        if (name == null) {
            LOG.warning("Missing name on <subsequence>");
            result = false;
        } else {
            createSubsequence(name);
            result = true;

            for (Node node : subseq.children) {

                if (node instanceof NonemptyElement) {
                    LOG.log(Level.WARNING, "Invalid element ''{0}'' within <subsequence>",
                        ((NonemptyElement) node).tagName);
                    result = false;

                    break;
                } else if (node instanceof EmptyElement) {
                    empty = (EmptyElement) node;

                    if ("time".equals(empty.tagName)) {

                        try {
                            this.addToSubsequence(name, LocalTime.parse(empty.get(VALUE_TAG)));
                        } catch (IllegalArgumentException e) {
                            LOG.log(Level.WARNING, "Invalid time point value ''{0}'' in <time>",
                                empty.get(VALUE_TAG));
                            result = false;

                            break;
                        }
                    } else {
                        LOG.log(Level.WARNING, "Invalid element ''{0}'' within <subsequence>",
                            empty.tagName);
                        result = false;

                        break;
                    }
                }
            }
        }

        return result;
    }

    /**
     * Creates a file that represents the object data.
     *
     * @param   dir  the directory where the pipe's files are stored
     * @return  the file
     */
    private File makeFile(final File dir) {

        return new File(dir, getKey() + "_TimeSeries.xml");
    }
}
