package com.srbenoit.filter;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.ParseException;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.zip.CRC32;
import com.srbenoit.log.LoggedObject;
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;

/**
 * A pipe from which filters can draw input data and to which they can add output data. Pipes
 * manage the storage of their items to a filesystem and reloading them when the pipe is created to
 * allow filters to avoid regenerating existing data.
 *
 * <p>Pipes contain a map from a <code>String</code> item key to an item that must be a subclass of
 * <code>AbstractPipeItem</code>. A pipe may contain any number of items. Pipes begin in an empty
 * state (no items). At any time, the pipe may be saved to the filesystem, and a saved pipe can be
 * reloaded.
 */
public class Pipe extends LoggedObject {

    /** version number for serialization */
    private static final long serialVersionUID = 6523308344875295475L;

    /** an object to compute file CRCs */
    private static final transient CRC32 CRC;

    /** line termination characters */
    public static final String CRLF;

    /** the directory where the pipe's data should be stored (or null) */
    private final transient File dir;

    /** the items in the pipe, each with unique key */
    private final Map<String, AbstractPipeItem> items;

    static {
        String crlf;

        crlf = System.getProperty("line.separator");
        CRLF = (crlf == null) ? "\n" : crlf;
        CRC = new CRC32();
    }

    /**
     * Constructs a new <code>Pipe</code>.
     *
     * @param  pipeDir  the directory where the pipe's data should be stored (if this is <code>
     *                  null</code>, the pipe cannot be persisted)
     */
    public Pipe(final File pipeDir) {

        super();

        if (pipeDir.exists()) {

            if (!pipeDir.isDirectory()) {
                throw new IllegalArgumentException("Invalid directory: "
                    + pipeDir.getAbsolutePath());
            }
        } else {

            if (!pipeDir.mkdirs()) {
                throw new IllegalArgumentException("Invalid directory: "
                    + pipeDir.getAbsolutePath());
            }
        }

        this.items = new TreeMap<String, AbstractPipeItem>();
        this.dir = pipeDir;
    }

    /**
     * Gets the directory where items in the pipe should be persisted to the filesystem.
     *
     * @return  the directory
     */
    public File getDir() {

        return this.dir;
    }

    /**
     * Adds an item to the pipe (usually the output of a filter).
     *
     * @param  item  the item to add
     */
    public void add(final AbstractPipeItem item) {

        this.items.put(item.getKey(), item);
    }

    /**
     * Tests whether the pipe contains a data item of a particular class and with a particular key.
     *
     * @param   key    the key to test for
     * @param   clazz  the class that the object with the given key must have
     * @return  <code>true</code> if the pipe has an object of the required class under the given
     *          key
     */
    public boolean hasItem(final String key, final Class<? extends AbstractPipeItem> clazz) {

        AbstractPipeItem item;
        boolean hasItem;

        item = get(key);

        if (item == null) {
            hasItem = false;
        } else {
            hasItem = item.getClass().equals(clazz);
        }

        return hasItem;
    }

    /**
     * Gets an item from the pipe.
     *
     * @param   key  the key of the item to get
     * @return  the item
     */
    public AbstractPipeItem get(final String key) {

        return this.items.get(key);
    }

    /**
     * Tests whether there is a complete persisted data set that can be loaded to fill the pipe
     * from the filesystem.
     *
     * @return  <code>true</code> if there is a complete persisted data set on the filesystem;
     *          <code>false</code> otherwise
     */
    public boolean isPersisted() {

        File file;
        List<Node> nodes;
        Node node;
        NonemptyElement pipe;
        boolean result;

        file = pipeInfoFile();
        nodes = parsePipeFile(file);

        // Root of tree should be 'pipe' node...
        if (nodes == null) {
            LOG.log(Level.WARNING, "Unable to parse pipe file: {0}",
                pipeInfoFile().getAbsolutePath());
            result = false;
        } else if (nodes.isEmpty()) {
            LOG.log(Level.WARNING, "Missing ''pipe'' node in pipe file: {0}",
                pipeInfoFile().getAbsolutePath());
            result = false;
        } else if (nodes.size() > 1) {
            LOG.log(Level.WARNING, "Multiple top-level nodes in pipe file: {0}",
                pipeInfoFile().getAbsolutePath());
            result = false;
        } else {
            node = nodes.get(0);

            if (node instanceof NonemptyElement) {
                pipe = (NonemptyElement) node;

                if ("pipe".equals(pipe.tagName)) {

                    // validate list of persisted items
                    result = true;

                    for (int i = 0; result && (i < pipe.children.size()); i++) {
                        result = validateItem(pipe.children.get(i));
                    }

                } else {
                    LOG.log(Level.WARNING, "Missing top-level ''pipe'' node in pipe file: {0}",
                        pipeInfoFile().getAbsolutePath());
                    result = false;
                }
            } else {
                LOG.log(Level.WARNING, "Invalid top-level node in pipe file: {0}",
                    pipeInfoFile().getAbsolutePath());
                result = false;
            }
        }

        return result;
    }

    /**
     * Gets the file where the pipe information is stored.
     *
     * @return  the file
     */
    private File pipeInfoFile() {

        return new File(this.dir, "pipe_info.xml");
    }

    /**
     * Reads the pipe information file and parses the XML into a node tree.
     *
     * @return  the parsed node tree, or <code>null</code> if parsing failed
     */
    private List<Node> parsePipeFile(final File file) {

        byte[] content;
        List<Node> nodes;

        if (file.exists()) {
            content = readFile(file);

            if (content == null) {
                nodes = null;
            } else {

                try {
                    nodes = new XmlParser().parse(new String(content), true);
                } catch (ParseException e) {
                    LOG.log(Level.WARNING, "Unable to parse pipe file", e);
                    nodes = null;
                }
            }
        } else {
            LOG.log(Level.WARNING, "Pipe file {0} not found", file.getAbsolutePath());
            nodes = null;
        }

        return nodes;
    }

    /**
     * validates a single pipe items information and tests whether the item's data files have the
     * correct size and SHA-1 hash.
     *
     * @param   itemNode  the item's node in the parsed pipe XML
     * @return  <code>true</code> if the item is valid and all indicated files are present and have
     *          the correct size and hash
     */
    private boolean validateItem(final Node itemNode) {

        NonemptyElement element;
        String key;
        String clsName;
        String label;
        String type;
        Class<?> clazz;
        boolean result;

        if (itemNode instanceof NonemptyElement) {

            element = (NonemptyElement) itemNode;

            if ("item".equals(element.tagName)) {
                key = element.get("key");
                clsName = element.get("class");
                label = element.get("label");
                type = element.get("type");

                // Make sure all required parameters are present
                if ((key == null) || (clsName == null) || (label == null) || (type == null)) {
                    LOG.log(Level.WARNING,
                        "Missing required attribute in item element in pipe file {0}",
                        pipeInfoFile().getAbsolutePath());
                    result = false;
                } else {

                    // Make sure the class name is a valid class
                    try {
                        clazz = Class.forName(clsName);

                        // Make sure the class extends AbstractPipeItem
                        result = false;

                        while (clazz != null) {

                            if (clazz.equals(AbstractPipeItem.class)) {
                                result = true;

                                break;
                            }

                            clazz = clazz.getSuperclass();
                        }

                        if (!result) {
                            LOG.log(Level.WARNING,
                                "Class ''{0}''in item element not a subclass of AbstractPipeItem in pipe file {1}",
                                new Object[] { clsName, pipeInfoFile().getAbsolutePath() });
                        }
                    } catch (ClassNotFoundException e) {
                        LOG.log(Level.WARNING,
                            "Invalid class ''{0}''in item element in pipe file {1}",
                            new Object[] { clsName, pipeInfoFile().getAbsolutePath() });
                        result = false;
                    }
                }

                if (result) {

                    // Class is valid, so verify files are valid
                    for (Node child : element.children) {

                        if (!validateItemFile(child)) {
                            result = false;

                            break;
                        }
                    }
                }
            } else {
                LOG.log(Level.WARNING,
                    "Invalid element ''{0}'' (expected ''item'') in pipe file {1}",
                    new Object[] { element.tagName, pipeInfoFile().getAbsolutePath() });
                result = false;
            }
        } else {
            LOG.log(Level.WARNING, "Invalid node in pipe file {0}",
                pipeInfoFile().getAbsolutePath());
            result = false;
        }

        return result;
    }

    /**
     * validates a single file within a pipe item and tests whether it has the correct size and
     * SHA-1 hash.
     *
     * @param   fileNode  the file's node in the parsed pipe item XML
     * @return  <code>true</code> if the file is present and has the correct size and hash
     */
    private boolean validateItemFile(final Node fileNode) {

        EmptyElement element;
        String name;
        String size;
        String crc;
        File test;
        boolean result;

        if (fileNode instanceof EmptyElement) {

            element = (EmptyElement) fileNode;

            if ("file".equals(element.tagName)) {

                name = element.get("name");
                size = element.get("size");
                crc = element.get("crc");

                test = new File(this.dir, name);

                if (test.exists()) {
                    result = checkFile(test, size, crc);
                } else {
                    LOG.log(Level.WARNING, "File ''{0}'' missing", test.getAbsolutePath());
                    result = false;
                }
            } else {
                LOG.log(Level.WARNING,
                    "Invalid element ''{0}'' (expected ''file'') in pipe file {1}",
                    new Object[] { element.tagName, pipeInfoFile().getAbsolutePath() });
                result = false;
            }
        } else {
            LOG.log(Level.WARNING, "Invalid file node in pipe file {0}",
                pipeInfoFile().getAbsolutePath());
            result = false;
        }

        return result;
    }

    /**
     * Checks the size and hash of a file against expected values.
     *
     * @param   file  the file to test
     * @param   size  the expected file size (string representation)
     * @param   crc   the expected file CRC
     * @return  <code>true</code> if the file has the correct size and hash
     */
    private boolean checkFile(final File file, final String size, final String crc) {

        String test;
        boolean result;

        test = Long.toString(file.length());

        if (test.equals(size)) {
            test = crcFile(file);

            result = test.equals(crc);

            if (!result) {
                LOG.log(Level.WARNING, "File ''{0}'' CRC mismatch (got {1}, expected {2})",
                    new Object[] { file.getAbsolutePath(), test, crc });
            }
        } else {
            LOG.log(Level.WARNING, "File ''{0}'' size mismatch (got {1}, expected {2})",
                new Object[] { file.getAbsolutePath(), test, size });
            result = false;
        }

        return result;
    }

    /**
     * Writes out a filled pipe to the filesystem.
     *
     * @param   executor  the filter tree executor that is running filters and saving the pipe from
     *                    time to time
     * @return  <code>true</code> if saving succeeded; <code>false</code> otherwise
     */
    public boolean save(final FilterTreeExecutor executor) {

        int total;
        int count;
        int finished;
        PipeItemFileInfo[] files;
        StringBuilder str;
        AbstractPipeItem item;
        boolean success;

        // Count the total number of item files that need persisting
        total = 0;
        finished = 0;

        for (String key : this.items.keySet()) {
            item = this.items.get(key);

            files = item.getFiles();

            for (int i = 0; i < files.length; i++) {

                if (!files[i].isPersisted()) {
                    total++;
                }
            }
        }

        str = new StringBuilder(500);

        str.append("<pipe>");
        str.append(CRLF);

        success = true;

        for (String key : this.items.keySet()) {
            item = this.items.get(key);

            str.append("  <item key='");
            str.append(ElementBase.encode(key));
            str.append("' class='");
            str.append(ElementBase.encode(item.getClass().getName()));
            str.append("' label='");
            str.append(ElementBase.encode(item.getLabel()));
            str.append("' type='");
            str.append(ElementBase.encode(item.typeName()));
            str.append("'>");
            str.append(CRLF);

            count = 0;
            files = item.getFiles();

            for (int i = 0; i < files.length; i++) {

                if (!files[i].isPersisted()) {
                    count++;
                }
            }

            success = item.save(executor, 80 + (20 * finished / total),
                    80 + (20 * (count + finished) / total));

            if (!success) {
                break;
            }

            finished += count;

            for (PipeItemFileInfo test : item.getFiles()) {
                str.append("    <file name='");
                str.append(ElementBase.encode(test.getFile().getName()));
                str.append("' size='");
                str.append(test.getSize());
                str.append("' crc='");
                str.append(test.getCrc());
                str.append("'/>");
                str.append(CRLF);
            }

            str.append("  </item>");
            str.append(CRLF);
        }

        str.append("</pipe>");
        str.append(CRLF);

        if (success) {
            success = writeFile(pipeInfoFile(), str.toString().getBytes());
        }

        return success;
    }

    /**
     * Loads the pipe from the persisted data set. If loading does not succeed, the pipe will be
     * returned to an empty state (any data in the pipe before loading was attempted will be lost).
     *
     * @return  <code>true</code> if loading succeeded and the pipe is now filled; <code>
     *          false</code> otherwise
     */
    public boolean load() {

        File file;
        List<Node> nodes;
        Node node;
        NonemptyElement pipe;
        NonemptyElement item;
        boolean result;

        file = pipeInfoFile();
        LOG.log(Level.INFO, "Attempting to load pipe from: {0}", file.getAbsolutePath());

        nodes = parsePipeFile(file);

        // Root of tree should be 'pipe' node...
        if (nodes == null) {
            LOG.log(Level.WARNING, "Unable to parse pipe file: {0}",
                pipeInfoFile().getAbsolutePath());
            result = false;
        } else if (nodes.isEmpty()) {
            LOG.log(Level.WARNING, "Missing ''pipe'' node in pipe file: {0}",
                pipeInfoFile().getAbsolutePath());
            result = false;
        } else if (nodes.size() > 1) {
            LOG.log(Level.WARNING, "Multiple top-level nodes in pipe file: {0}",
                pipeInfoFile().getAbsolutePath());
            result = false;
        } else {
            node = nodes.get(0);

            if (node instanceof NonemptyElement) {
                pipe = (NonemptyElement) node;

                if ("pipe".equals(pipe.tagName)) {

                    // Load all persisted items
                    result = true;

                    for (Node child : pipe.children) {

                        if (child instanceof NonemptyElement) {
                            item = (NonemptyElement) child;

                            if ("item".equals(item.tagName)) {
                                result = loadItem(item);

                                if (!result) {
                                    break;
                                }
                            }
                        }
                    }
                } else {
                    LOG.log(Level.WARNING, "Missing top-level ''pipe'' node in pipe file: {0}",
                        pipeInfoFile().getAbsolutePath());
                    result = false;
                }
            } else {
                LOG.log(Level.WARNING, "Invalid top-level node in pipe file: {0}",
                    pipeInfoFile().getAbsolutePath());
                result = false;
            }
        }

        return result;
    }

    /**
     * Loads a single item in the persisted pipe.
     *
     * @param   item  the node describing the item to load
     * @return  the loaded item
     */
    @SuppressWarnings("unchecked")
    private boolean loadItem(final NonemptyElement item) {

        String key;
        String clsName;
        String label;
        Class<?> cls;
        Class<? extends AbstractPipeItem> clazz;
        AbstractPipeItem obj;
        boolean result;

        key = item.get("key");

        if (key == null) {
            LOG.warning("Missing 'key' tag in <info> element");
            result = false;
        } else {
            clsName = item.get("class");

            if (clsName == null) {
                LOG.warning("Missing 'class' tag in <info> element");
                result = false;
            } else {
                label = item.get("label");

                if (label == null) {
                    LOG.warning("Missing 'label' tag in <info> element");
                    result = false;
                } else {

                    try {
                        cls = Class.forName(clsName);

                        if (AbstractPipeItem.class.isAssignableFrom(cls)) {
                            clazz = (Class<? extends AbstractPipeItem>) cls;
                            obj = AbstractPipeItem.getInstance(clazz, key, label, this);
                            result = obj.load();

                            if (result) {
                                this.items.put(obj.getKey(), obj);
                            }
                        } else {
                            LOG.log(Level.WARNING,
                                "Class ({0}) specified in ''class'' tag in <info> element is not a subclass of AbstractPipeItem",
                                clsName);
                            result = false;
                        }
                    } catch (ClassNotFoundException e) {
                        LOG.log(Level.WARNING,
                            "Invalid class ({0}) specified in ''class'' tag in <info> element",
                            clsName);
                        result = false;
                    }
                }
            }
        }

        return result;
    }

    /**
     * A utility method to write a file.
     *
     * @param   file  the <code>File</code> to which to write
     * @param   data  the data to write to the file
     * @return  <code>true</code> if the file was written successfully; <code>false</code> if not
     */
    public static boolean writeFile(final File file, final byte[] data) {

        FileOutputStream out;
        boolean result;

        try {

            if (!file.getParentFile().exists()) {
                file.getParentFile().mkdirs();
            }

            out = new FileOutputStream(file);

            try {
                out.write(data);
                result = true;
            } finally {
                out.close();
            }
        } catch (IOException e) {
            LOG.log(Level.WARNING, "Exception while writing file '" + file.getAbsolutePath() + "'",
                e);
            result = false;
        }

        return result;
    }

    /**
     * A utility method to read a file.
     *
     * @param   file  the <code>File</code> from which to read
     * @return  the data read from the file, or <code>null</code> on any error
     */
    public static byte[] readFile(final File file) {

        int len;
        int total;
        int count;
        FileInputStream input;
        byte[] result;

        try {
            len = (int) file.length();
            result = new byte[len];
            input = new FileInputStream(file);

            try {
                total = input.read(result);

                if (total == -1) {
                    LOG.log(Level.WARNING, "Premature end of file on file ''{0}''",
                        file.getAbsolutePath());
                    result = null;
                } else {

                    while (total < len) {
                        count = input.read(result, total, len - total);

                        if (count == -1) {
                            LOG.log(Level.WARNING, "Premature end of file on file ''{0}''",
                                file.getAbsolutePath());
                            result = null;

                            break;
                        }
                    }
                }
            } finally {
                input.close();
            }
        } catch (IOException e) {
            LOG.log(Level.WARNING, "Exception while reading file '" + file.getAbsolutePath() + "'",
                e);
            result = null;
        }

        return result;
    }

    /**
     * Computes the CRC-32 of a file.
     *
     * @param   file  the file whose CRC is to be computed
     * @return  the file hash, or <code>null</code> if the file did not exist or could not be read
     */
    public static String crcFile(final File file) {

        FileInputStream input;
        int length;
        int total;
        int count;
        byte[] buffer;
        String crc;

        if (CRC == null) {
            LOG.warning("No CRC computation engine");
            crc = null;
        } else {

            buffer = new byte[1024];
            length = (int) file.length();

            try {
                input = new FileInputStream(file);

                total = input.read(buffer);

                if (total < 1) {
                    LOG.warning("CRC: failed to read from file");
                    crc = null;
                } else {

                    synchronized (CRC) {
                        CRC.reset();
                        CRC.update(buffer, 0, total);

                        while (total < length) {
                            count = input.read(buffer);

                            if (count < 1) {
                                break;
                            }

                            CRC.update(buffer, 0, count);
                            total += count;
                        }
                    }

                    if (total == length) {
                        crc = Long.toString(CRC.getValue());
                    } else {
                        LOG.warning("CRC: failed to read all of file");
                        crc = null;
                    }

                    input.close();
                }
            } catch (IOException e) {
                LOG.log(Level.WARNING, "Exception while computing CRC", e);
                crc = null;
            }
        }

        return crc;
    }
}
