/*
 * $Id$
 *
 * Copyright 1996-2008 Sun Microsystems, Inc.  All Rights Reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Sun designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Sun in the LICENSE file that accompanied this code.
 *
 * This code 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
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Sun Microsystems, Inc., 4150 Network Circle, Santa Clara,
 * CA 95054 USA or visit www.sun.com if you need additional information or
 * have any questions.
 */
package com.sun.javatest.finder;

import java.io.BufferedOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.Vector;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;


import com.sun.javatest.TestDescription;
import com.sun.javatest.TestFinder;

/**
 * BinaryTestWriter creates the data file used by BinaryTestFinder.
 * It uses a test finder to find all the tests in a test suite and writes
 * them out in a compact compressed form. By default it uses the standard
 * tag test finder, and writes the output in a file called
 * testsuite.jtd in the root directory of the test suite.
 * <br>
 * Options:
 * <dl>
 * <dt>-finder finderClass finderArgs ... -end
 * <dd>the test finder to be used to locate the tests; the default is the standard tag test finder
 * <dt>-strictFinder
 * <dd>Do not ignore errors from the source finder, exit with error code instead
 * <dt>-o output-file
 * <dd>specify the name of the output file; the default is testsuite.jtd in the root directory of the test suite.
 * <dt>testsuite
 * <dd>(Required.) The test suite root file.
 * <dt>initial-files
 * <dd>(Optional)Any initial starting points within the test suite: the default is the test suite root
 * </dl>
 */
public class BinaryTestWriter
{
    /**
     * This exception is used to report bad command line arguments.
     */
    public class BadArgs extends Exception {
        /**
         * Create a BadArgs exception.
         * @param msg A detail message about an error that has been found.
         */
        BadArgs(String msg) {
            super(msg);
        }
    }

    /**
     * This exception is used to report problems that occur while running.
     */

    public class Fault extends Exception {
        /**
         * Create a Fault exception.
         * @param msg A detail message about a fault that has occurred.
         */
        Fault(String msg) {
            super(msg);
        }
    }

    //------------------------------------------------------------------------------------------

    /**
     * Standard program entry point.
     * @param args      An array of strings, typically provided via the command line.
     * The arguments should be of the form:<br>
     * <em>[options]</em> <em>testsuite</em> <em>[tests]</em>
     * <table><tr><th colspan=2>Options</th></tr>
     * <tr><td>-finder <em>finderClass</em> <em>finderArgs</em> <em>...</em> -end
     *          <td>The name of a test finder class and any arguments it might take.
     *          The results of reading this test finder will be stored in the
     *          output file.
     * <tr><td>-o <em>output-file</em>
     *          <td>The output file in which to write the results.
     * </table>
     */
    public static void main(String[] args) {
        int result = 0;

        try {
            BinaryTestWriter m = new BinaryTestWriter();
            result = m.run(args);
        }
        catch (BadArgs e) {
            System.err.println("Bad Arguments: " + e.getMessage());
            usage(System.err);
            System.exit(1);
        }
        catch (Fault f) {
            System.err.println("Error: " + f.getMessage());
            System.exit(2);
        }
        catch (IOException e) {
            System.err.println("Error: " + e);
            System.exit(3);
        }

        System.exit(result);
    }

    /**
     * Print out command-line help.
     */
    private static void usage(PrintStream out) {
        String prog = System.getProperty("program", "java " + BinaryTestWriter.class.getName());
        out.println("Usage:");
        out.println("  " + prog + " [options]  test-suite [tests...]");
        out.println("Options:");
        out.println("  -finder finderClass finderArgs... -end");
        out.println("  -o output-file");
        out.println("  -strictFinder");
    }

    //------------------------------------------------------------------------------------------

    /**
     * Main work method.
     * Reads all the arguments on the command line, makes sure a valid
     * testFinder is available, and then calls methods to create the tree of tests
     * and then write the binary file.
     * @param args      An array of strings, typically provided via the command line
     * @return The disposition of the run, i.e. zero for a problem-free execution, non-zero
     *         if there was some sort of problem.
     * @throws BinaryTestWriter.BadArgs
     *                  if a problem is found in the arguments provided
     * @throws BinaryTestWriter.Fault
     *                  if a fault is found while running
     * @throws IOException
     *                  if a problem is found while trying to read a file
     *                  or write the output file
     * @see #main
     */
    public int run(String[] args) throws BadArgs, Fault, IOException {
        File testSuite = null;
        String finder = "com.sun.javatest.finder.TagTestFinder";
        String[] finderArgs = { };
        File outFile = null;
        File[] tests = null;

        for (int i = 0; i < args.length; i++) {
            if (args[i].equalsIgnoreCase("-finder") && (i + 1 < args.length)) {
                finder = args[++i];
                int j = ++i;
                while ((i < args.length - 1) && !(args[i].equalsIgnoreCase("-end")))
                    ++i;
                finderArgs = new String[i - j];
                System.arraycopy(args, j, finderArgs, 0, finderArgs.length);
            }
            else if (args[i].equalsIgnoreCase("-o") && (i + 1 < args.length)) {
                outFile = new File(args[++i]);
            }
            else if (args[i].equalsIgnoreCase("-strictFinder")) {
                strictFinder = true;
            }
            else if (args[i].startsWith("-") ) {
                throw new BadArgs(args[i]);
            }
            else {
                testSuite = new File(args[i++]);

                if (i < args.length) {
                    tests = new File[args.length - i];
                    for (int j = 0; j < tests.length; j++)
                        tests[j] = new File(args[i + j]);
                }
                break;
            }
        }

        if (testSuite == null)
            throw new BadArgs("testsuite.html file not specified");

        TestFinder testFinder = initializeTestFinder(finder, finderArgs, testSuite);

        if (tests == null)
            tests = new File[] { testFinder.getRoot() }; // equals testSuite, adjusted by finder as necessary .. e.g. for dirWalk, webWalk etc

        if (outFile == null)
            outFile = new File(testFinder.getRootDir(), "testsuite.jtd");

        if (strictFinder) {
            testFinder.setErrorHandler(new TestFinder.ErrorHandler() {
                    public void error(String msg) {
                        numFinderErrors++;
                        System.err.println("Finder reported error:\n" + msg);
                        System.err.println("");
                    }
                }
            );
        }

        StringTable stringTable = new StringTable();
        TestTable testTable = new TestTable(stringTable);
        TestTree testTree = new TestTree(testTable);

        if (log != null)
            log.println("Reading tests...");

        // read the tests into internal data structures
        read(testFinder, tests, testTree);

        if (testTree.getSize() == 0)
            throw new Fault("No tests found -- check arguments.");

        // write out the data structure into a zip file
        if (log != null)
            log.println("Writing " + outFile);

        ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(outFile)));
        zos.setMethod(ZipOutputStream.DEFLATED);
        zos.setLevel(9);
        ZipEntry stringZipEntry = stringTable.write(zos);
        ZipEntry testTableZipEntry = testTable.write(zos);
        ZipEntry testTreeZipEntry = testTree.write(zos);
        zos.close();

        // report statistics
        if (log != null) {
            log.println("strings: " + stringTable.getSize() + " entries, " + zipStats(stringZipEntry));
            log.println("tests: " + testTable.getSize() + " tests, " + zipStats(testTableZipEntry));
            log.println("tree: " + testTree.getSize() + " nodes, " + zipStats(testTreeZipEntry));
        }

        if (strictFinder && numFinderErrors > 0) {
            System.err.println("*** Source finder reported " + numFinderErrors + " errors during execution. ***");
            return 4;
        }
        else {
            return 0;
        }
    }

    /**
     * Creates and initializes an instance of a test finder
     *
     * @param finder The class name of the required test finder
     * @param args any args to pass to the TestFinder's init method.
     * @param ts The testsuite root file
     * @return The newly created TestFinder.
     */
    private TestFinder initializeTestFinder(String finder, String[] args, File ts) throws Fault {
        TestFinder testFinder;

        if (ts == null)
            throw new NullPointerException();

        try {
            Class c = Class.forName(finder);
            testFinder = (TestFinder) (c.newInstance());
            testFinder.init(args, ts, null);
        }
        catch (ClassNotFoundException e) {
            throw new Fault("Error: Can't find class for test finder specified: " + finder);
        }
        catch (InstantiationException e) {
            throw new Fault("Error: Can't create new instance of test finder: " + e);
        }
        catch (IllegalAccessException e) {
            throw new Fault("Error: Can't access test finder: " + e);
        }
        catch (TestFinder.Fault e) {
            throw new Fault("Error: Can't initialize test-finder: " + e.getMessage());
        }

        return testFinder;
    }


    /**
     * Gets and returns the test suite file. Adds testsuite.html or
     * tests/testsuite.html to the end of the path if necessary.
     */
    private File getTestSuiteFile(String file) throws Fault {
        File tsa = new File(file);
        if (tsa.isFile())
            return tsa;
        else {
            File tsb = new File(tsa, "testsuite.html");
            if (tsb.exists())
                return tsb;
            else {
                File tsc = new File(tsa, "tests/testsuite.html");
                if (tsc.exists())
                    return tsc;
                else
                    throw new Fault("Bad input. " + file + " is not a JCK");
            }
        }
    }

    /**
     * Create a string containing statistics about a zip file entry.
     */
    private String zipStats(ZipEntry e) {
        long size = e.getSize();
        long csize = e.getCompressedSize();
        return size + " bytes (" + csize + " compressed, " + (csize * 100 / size) + "%)";
    }

    //------------------------------------------------------------------------------------------

    /**
     * Read all the tests from a test suite and store them in a test tree
     */
    void read(TestFinder finder, File[] files, TestTree testTree) throws Fault
    {
        if (files.length < 1)
            throw new IllegalArgumentException();

        File rootDir = finder.getRootDir();
        Set allFiles = new HashSet();

        TestTree.Node r = null;
        for (int i = 0; i < files.length; i++) {
            File f = files[i];
            if (!f.isAbsolute())
                f = new File(rootDir, f.getPath());

            TestTree.Node n = read0(finder, f, testTree, allFiles);
            if (n == null)
                continue;

            while (!f.equals(rootDir)) {
                f = f.getParentFile();
                n = testTree.new Node(f.getName(), noTests, new TestTree.Node[] { n });
            }

            r = (r == null ? n : r.merge(n));
        }

        if (r == null)
            throw new Fault("No tests found");

        testTree.setRoot(r);
    }

    /**
     * Read the tests from a file in test suite
     */
    private TestTree.Node read0(TestFinder finder, File file, TestTree testTree, Set allFiles)
    {
        // keep track of which files we have read, and ignore duplicates
        if (allFiles.contains(file))
            return null;
        else
            allFiles.add(file);

        finder.read(file);
        TestDescription[] tests = finder.getTests();
        File[] files = finder.getFiles();

        if (tests.length == 0 && files.length == 0)
            return null;

        Arrays.sort(files);
        Arrays.sort(tests, new Comparator() {
            public int compare(Object o1, Object o2) {
                TestDescription td1 = (TestDescription) o1;
                TestDescription td2 = (TestDescription) o2;
                return td1.getRootRelativeURL().compareTo(td2.getRootRelativeURL());
            }
        });

        Vector v = new Vector();
        for (int i = 0; i < files.length; i++) {
            TestTree.Node n = read0(finder, files[i], testTree, allFiles);
            if (n != null)
                v.addElement(n);
        }
        TestTree.Node[] nodes = new TestTree.Node[v.size()];
        v.copyInto(nodes);

        return testTree.new Node(file.getName(), tests, nodes);
    }

    //------------------------------------------------------------------------------------------

    /**
     * Write an int to a data output stream using a variable length encoding.
     * The int is broken into groups of seven bits, and these are written out
     * in big-endian order. Leading zeroes are suppressed and all but the last
     * byte have the top bit set.
     * @see BinaryTestFinder#readInt
     */
    private static void writeInt(DataOutputStream out, int v) throws IOException {
        if (v < 0)
            throw new IllegalArgumentException();

        boolean leadZero = true;
        for (int i = 28; i > 0; i -= 7) {
            int b = (v >> i) & 0x7f;
            leadZero = leadZero && (b == 0);
            if (!leadZero)
                out.writeByte(0x80 | b);
        }
        out.writeByte(v & 0x7f);
    }

    //------------------------------------------------------------------------------------------

    private static final TestDescription[] noTests = { };
    private PrintStream log = System.out;
    private boolean strictFinder = false;
    private int numFinderErrors = 0;

    //------------------------------------------------------------------------------------------

    /**
     * StringTable is an array of strings. Other parts of the encoding can
     * choose to write strings as references (indexes) into the string table.
     * Strings in the table are use-counted so that only frequently used
     * strings are output.
     * @see BinaryTestFinder.StringTable
     */
    static class StringTable {
        /**
         * Add a new string to the table; if it has already been added,
         * increase its use count.
         */
        void add(String s) {
            Entry e = (Entry) (map.get(s));
            if (e == null) {
                e = new Entry();
                map.put(s, e);
            }
            e.useCount++;
        }

        /**
         * Add all the strings used in a test description to the table.
         */
        void add(TestDescription test) {
            for (Iterator i = test.getParameterKeys(); i.hasNext(); ) {
                String key = (String) (i.next());
                String param = test.getParameter(key);
                add(key);
                add(param);
            }
        }

        /**
         * Return the number of strings in the table.
         */
        int getSize() {
            return map.size();
        }

        /**
         * Return the number of sstrings that were written to the output file.
         * Not all strings are written out: only frequently used ones are.
         */
        int getWrittenSize() {
            return writtenSize;
        }

        /**
         * Get the index of a string in the table.
         */
        int getIndex(String s) {
            Entry e = (Entry) (map.get(s));
            if (e == null)
                throw new IllegalArgumentException();
            return e.index;
        }

        /**
         * Write the contents of the table to an entry called "strings"
         * in a zip file.
         */
        ZipEntry write(ZipOutputStream zos) throws IOException
        {
            ZipEntry entry = new ZipEntry("strings");
            zos.putNextEntry(entry);
            DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(zos));
            write(dos);
            dos.flush();
            zos.closeEntry();
            return entry;
        }

        /**
         * Write the contents of the table to a stream
         */
        void write(DataOutputStream o) throws IOException {
            Vector v = new Vector(map.size());
            v.addElement("");
            int nextIndex = 1;
            for (Iterator iter = map.entrySet().iterator(); iter.hasNext(); ) {
                Map.Entry e = (Map.Entry) (iter.next());
                String key = (String) (e.getKey());
                Entry entry = (Entry) (e.getValue());
                if (entry.isFrequent()) {
                    entry.index = nextIndex++;
                    v.addElement(key);
                }
            }

            writeInt(o, v.size());
            for (int i = 0; i < v.size(); i++)
                o.writeUTF((String) (v.elementAt(i)));

            writtenSize = nextIndex;
        }

        /**
         * Write a reference to a string to a stream.  The string must have
         * previously been added into nthe string table, and the string table
         * written out.
         * If the string is a frequent one, a pointer to its position in the
         * previously written stream will be generated. If it is not a frequent
         * string, zero will be written, followed by the value of the string itself.
         */
        void writeRef(String s, DataOutputStream o) throws IOException {
            Entry e = (Entry) (map.get(s));
            if (e == null)
                throw new IllegalArgumentException();

            if (e.isFrequent())
                writeInt(o, e.index);
            else {
                writeInt(o, 0);
                o.writeUTF(s);
            }
        }

        private TreeMap map = new TreeMap();
        private int writtenSize;

        /**
         * Data for each string in the string table.
         */
        static class Entry {
            /**
             * How many times the string has been added to the string table.
             */
            int useCount = 0;

            /**
             * The position of the string in the table when the table
             * was written.
             */
            int index = 0;

            /**
             * Determine if the string is frequent enough in the table to
             * be written out.
             */
            boolean isFrequent() {
                return (useCount > 1);
            }
        }
    }

    //------------------------------------------------------------------------------------------

    /**
     * TestTable is a table of test descriptions, whose written form is
     * based on references into a string table.
     * @see BinaryTestFinder.TestTable
     */
    static class TestTable
    {
        /**
         * Create a new TestTable.
         */
        TestTable(StringTable stringTable) {
            this.stringTable = stringTable;
        }

        /**
         * Add a test decsription to the test table. The strings used by the
         * test description are automatically added to the testTable's stringTable.
         */
        void add(TestDescription td) {
            tests.addElement(td);
            testMap.put(td, new Entry());
            stringTable.add(td);
        }

        /**
         * Get the number of tests in this test table.
         */
        int getSize() {
            return tests.size();
        }

        /**
         * Get the index for a test description, based on its position when the
         * test table was written out. This index is the byte offset in the
         * written stream.
         */
        int getIndex(TestDescription td) {
            Entry e = (Entry) (testMap.get(td));
            if (e == null)
                throw new IllegalArgumentException();
            return e.index;
        }

        /**
         * Write the contents of the table to an entry called "tests"
         * in a zip file.
         */
        ZipEntry write(ZipOutputStream zos) throws IOException
        {
            ZipEntry entry = new ZipEntry("tests");
            zos.putNextEntry(entry);
            DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(zos));
            write(dos);
            dos.flush();
            zos.closeEntry();
            return entry;
        }

        /**
         * Write the contents of the table to a stream. The position of each test
         * description in the stream is recorded, so that a random acess stream
         * can randomly access the individual test descriptions. The table is
         * written as a count, followed by that many encoded test descriptions.
         * Each test description is written as a count followed by that many
         * name-value pairs of string references.
         */
        void write(DataOutputStream o) throws IOException {
            writeInt(o, tests.size());
            for (int i = 0; i < tests.size(); i++) {
                TestDescription td = (TestDescription) (tests.elementAt(i));
                Entry e = (Entry) (testMap.get(td));
                e.index = o.size();
                write(td, o);
            }
        }

        /**
         * Write a single test description to a stream. It is written as a count,
         * followed by that many name-value pairs of string references.
         */
        private void write(TestDescription td, DataOutputStream o) throws IOException {
            // should consider using load/save here
            writeInt(o, td.getParameterCount());
            for (Iterator i = td.getParameterKeys(); i.hasNext(); ) {
                String key = (String) (i.next());
                String value = td.getParameter(key);
                stringTable.writeRef(key, o);
                stringTable.writeRef(value, o);
            }
        }

        private HashMap testMap = new HashMap();
        private Vector tests = new Vector();
        private StringTable stringTable;

        /**
         * Data for each test description in the table.
         */
        class Entry {
            /**
             * The byte offset of the test description in the stream when
             * last written out.
             */
            int index = -1;
        }
    }

    //------------------------------------------------------------------------------------------

    /**
     * TestTree is a tree of tests, whose written form is based on
     * references into a TestTable. There is a very strong correspondence
     * between a node and the results of reading a file from a test finder,
     * which yields a set of test descriptions and a set of additional files
     * to be read.
     * @see BinaryTestFinder.TestTable
     */
    static class TestTree
    {
        /**
         * Create an test tree. The root node of the tree should be set later.
         */
        TestTree(TestTable testTable) {
            this.testTable = testTable;
        }

        /**
         * Set the root node of the tree.
         */
        void setRoot(Node root) {
            this.root = root;
        }

        /**
         * Get the number of nodes in this tree.
         */
        int getSize() {
            return (root == null ? 0 : root.getSize());
        }

        /**
         * Write the contents of the tree to an entry called "tree"
         * in a zip file.
         */
        ZipEntry write(ZipOutputStream zos) throws IOException
        {
            ZipEntry entry = new ZipEntry("tree");
            zos.putNextEntry(entry);
            DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(zos));
            write(dos);
            dos.flush();
            zos.closeEntry();
            return entry;
        }

        /**
         * Write the contents of the tree to a stream. Each node of the tree
         * is written as 3 parts:
         * <ul>
         * <li>the name of the node
         * <li>the number of test descriptions in this node, followed by that
         * many references into the test table.
         * <li>the number of child nodes, followed by that many nodes, written
         * recursively.
         * </ul>
         */
        void write(DataOutputStream o) throws IOException {
            root.write(o);
        }

        private Node root;
        private TestTable testTable;

        /**
         * A node within the test tree. Each node has a name, a set of test
         * descriptions, and a set of child nodes.
         */
        class Node
        {
            /**
             * Create a node. The individual test descriptions are added to
             * the tree's test table.
             */
            Node(String name, TestDescription[] tests, Node[] children) {
                this.name = name;
                this.tests = tests;
                this.children = children;

                for (int i = 0; i < tests.length; i++)
                    testTable.add(tests[i]);
            }

            /**
             * Get the number of nodes at this point in the tree: count one
             * for this node and add the size of all its children.
             */
            int getSize() {
                int n = 1;
                if (children != null) {
                    for (int i = 0; i < children.length; i++)
                        n += children[i].getSize();
                }
                return n;
            }

            /**
             * Merge the contents of this node with another to produce
             * a new node.
             * @param other The node to be merged with this one.
             * @return a new Node, containing the merge of this one
             * and the specified node.
             */
            Node merge(Node other) {
                if (!other.name.equals(name))
                    throw new IllegalArgumentException(name + ":" + other.name);

                TreeMap mergedChildrenMap = new TreeMap();
                for (int i = 0; i < children.length; i++) {
                    Node child = children[i];
                    mergedChildrenMap.put(child.name, child);
                }
                for (int i = 0; i < other.children.length; i++) {
                    Node otherChild = other.children[i];
                    Node c = (Node) (mergedChildrenMap.get(otherChild.name));
                    mergedChildrenMap.put(otherChild.name,
                                      (c == null ? otherChild : otherChild.merge(c)));
                }
                Node[] mergedChildren =
                    (Node[]) (mergedChildrenMap.values().toArray(new Node[mergedChildrenMap.size()]));

                TestDescription[] mergedTests;
                if (tests.length + other.tests.length == 0)
                    mergedTests = noTests;
                else {
                    mergedTests = new TestDescription[tests.length + other.tests.length];
                    System.arraycopy(tests, 0, mergedTests, 0, tests.length);
                    System.arraycopy(other.tests, 0, mergedTests, tests.length, other.tests.length);
                }

                return new Node(name, mergedTests, mergedChildren);
            }

            /**
             * Write the contents of a node to a stream. First the name
             * is written, then the number of test descriptions, followed
             * by that many references to the test table, then the number
             * of child nodes, followed by that many child nodes in place.
             */
            void write(DataOutputStream o) throws IOException {
                o.writeUTF(name);
                writeInt(o, tests.length);
                for (int i = 0; i < tests.length; i++)
                    writeInt(o, testTable.getIndex(tests[i]));
                writeInt(o, children.length);
                for (int i = 0; i < children.length; i++)
                    children[i].write(o);
            }

            private String name;
            private TestDescription[] tests;
            private Node[] children;
        }
    }

}
