/*
 * $Id$
 *
 * Copyright (c) 1996, 2011, Oracle and/or its affiliates. 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.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle 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 Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */
package com.sun.javatest.finder;

import com.sun.javatest.TestDescription;
import com.sun.javatest.TestEnvironment;
import com.sun.javatest.TestFinder;
import com.sun.javatest.util.I18NResourceBundle;

import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;


/**
 * A TestFinder to read a compressed binary file containing the
 * previously "compiled" results of some other test finder.
 */

public class BinaryTestFinder extends TestFinder {
    private static I18NResourceBundle i18n = I18NResourceBundle.getBundleForClass(BinaryTestFinder.class);
    private File jtdFile;
    private ZipFile zipFile;
    private ZipEntry stringsEntry;
    private ZipEntry testsEntry;
    private ZipEntry treeEntry;
    private boolean zipFileRead;
    private StringTable stringTable;
    private TestTable testTable;
    private TestTree testTree;

    /**
     * Create an uninitialized binary test finder. Use one of the init
     * methods to initialize it.
     */
    public BinaryTestFinder() {
    }

    /**
     * Create and initialize a binary test finder.
     *
     * @param jtdFile The binary file containing the precompiled test descriptions.
     *                The file should be an absolute file.
     * @throws TestFinder.Fault if there is a problem while reading the data file.
     * @see #BinaryTestFinder(File, File)
     * @deprecated Use BinaryTestFinder(File testSuiteRoot, File jtdFile) instead
     */
    @Deprecated
    public BinaryTestFinder(File jtdFile) throws Fault {
        this.jtdFile = jtdFile;
        openBinaryFile(true);
    }

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

    /**
     * Create and initialize a binary test finder.
     *
     * @param jtdFile       The binary file containing the precompiled test descriptions.
     *                      If the file is relative, it will be evaluated relative to testSuiteRoot.
     * @param testSuiteRoot The root file of the Test Suite.
     *                      This is usually a file called testsuite.html
     * @throws TestFinder.Fault if there is a problem while reading the data file.
     * @deprecated Use BinaryTestFinder(File, File, TestEnvironment) with null as
     * the last argument.
     */
    @Deprecated
    public BinaryTestFinder(File testSuiteRoot, File jtdFile) throws Fault {
        init(testSuiteRoot, jtdFile);
    }

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

    /**
     * Read an int from a stream using a variable length encoding.
     * The integer is read in 7 bit chunks in big-endian order, with
     * the top bit in each byte indicating more data to be read.
     */
    private static int readInt(DataInputStream in) throws IOException {
        int n = 0;
        int b;
        while ((b = in.readUnsignedByte()) >= 0x80) {
            n = (n << 7) | (b & 0x7f);
        }
        n = (n << 7) | b;
        return n;
    }

    /**
     * Initialize a new BinaryTestFinder. This method opens the binary file to read
     * from, calls methods to read the data, and initializes other variables.
     *
     * @param args          Any arguments needed by the TestFinder. You must pass "-binary"
     *                      followed by the path to the binary file. If the file is relative,
     *                      it will be evaluated relative to testSuiteRoot.
     * @param testSuiteRoot The root file of the Test Suite.
     *                      This is usually a file called testsuite.html
     * @param env           Environment file for the TestFinder. Not used by BinaryTestFinder.
     * @throws TestFinder.Fault if there is a problem while reading the data file.
     */
    @Override
    public void init(String[] args, File testSuiteRoot, TestEnvironment env) throws Fault {
        super.init(args, testSuiteRoot, env);
        openBinaryFile(true);
    }

    /**
     * Initialize a new BinaryTestFinder. This method opens the binary file to read
     * from, calls methods to read the data, and initializes other variables.
     *
     * @param testSuiteRoot The root file of the Test Suite.
     *                      This is usually a file called testsuite.html
     * @param jtdFile       The file containing the binary data for the test descriptions.
     *                      If the file is relative, it will be evaluated relative to testSuiteRoot.
     * @throws TestFinder.Fault if there is a problem while reading the data file.
     * @deprecated Use init(File, File TestEnvironment) with null args as needed.
     */
    @Deprecated
    public void init(File testSuiteRoot, File jtdFile) throws Fault {
        super.init(null, testSuiteRoot, null);
        this.jtdFile = jtdFile;
        openBinaryFile(true);
    }

    /**
     * Decodes any args needed by BinaryTestFinder. The only supported args
     * are "-binary" or "-jtd" followed by the name of the binary file,
     * or the name of the binary file as the last argument.
     *
     * @param args An array of arguments, containing the next argument to be
     *             decoded.
     * @param i    The position in the args array of the next argument to be
     *             decoded.
     * @return The number of entries in the args array that were
     * taken as part of the next argument.
     * @throws TestFinder.Fault if any problems occur while decoding the next argument
     */
    @Override
    protected int decodeArg(String[] args, int i) throws Fault {
        if (args[i].equalsIgnoreCase("-binary") || args[i].equalsIgnoreCase("-jtd")) {
            String e = args[i + 1];
            jtdFile = new File(e);
            return 2;
        } else if (i == args.length - 1 && !args[i].startsWith("-")) {
            jtdFile = new File(args[i]);
            return 1;
        } else {
            return super.decodeArg(args, i);
        }
    }

    @Override
    public long lastModified(File f) {
        // with BTF, the file will never change, so rescanning should never be
        // needed.  And, after the first scan, 1 will always be earlier than the
        // current date, so rescanning won't be triggered again.
        return 1;
    }

    @Override
    public boolean isFolder(File path) {
        if (zipFile != null || zipFileRead) {
            readBinaryFile();
        }

        File rootDir = getRootDir();
        String relPath = getRelativePath(rootDir, path);
        TestTree.Node node = testTree == null ? null : testTree.getNode(relPath);

        /*
        System.out.println("isFolder() for " + path.getPath() + " = " +
            (node == null ? false : true));
        */

        // temporary fix which will handle most TCKs
        // need to find algorithm to determine if the path is a test in
        // a lightweight manner
        if (path.getPath().endsWith(".html") ||
                path.getPath().endsWith(".java") ||
                path.getPath().endsWith(".xml")) {
            return false;
        }

        return node != null;
    }

    /**
     * Given a File, scan to look for other files or tests. These can
     * then be used through getTests() and getFiles().
     * <p>
     * This method first takes the file path and makes it relative to the testsuite
     * root. It then finds the corresponding node in the test tree, and calls
     * foundFile and foundTestDescription based on the info in that node, thereby
     * recreating the effect of the original read of this file by the original
     * test finder.
     */
    @Override
    protected void scan(File file) {
        if (zipFile != null || zipFileRead) {
            readBinaryFile();
        }

        try {
            // locate the node for this file
            File rootDir = getRootDir();
            String relPath = getRelativePath(rootDir, file);
            TestTree.Node node = testTree == null ? null : testTree.getNode(relPath);

            if (node == null) {
                error(i18n, "bin.cantFindPath", file.getPath());
                return;
            }

            // simulate files to be scanned for child nodes
            TestTree.Node[] children = node.children;
            if (children != null) {
                for (TestTree.Node child : children) {
                    File f = relPath.isEmpty() ? new File(child.name) :
                            new File(relPath, child.name);
                    foundFile(f);
                }
            }

            // regenerate test descriptions
            if (node.testIndexes != null) {
                File testFile = new File(relPath);
                for (int i = 0; i < node.testIndexes.length; i++) {
                    TestDescription td = node.getTest(i, rootDir, testFile, testTable);
                    foundTestDescription(td);
                }
            }
        } catch (IOException e) {
            error(i18n, "bin.internalIOError", file.getPath(), e);
        }
    }

    /**
     * Create the relative path of a filename relative to a root directory.
     *
     * @throws IllegalArgumentException is the file is not under the root
     *                                  directory.
     */
    private String getRelativePath(File rootDir, File file) {
        if (file.isAbsolute()) {
            String rootDirPath = rootDir.getPath();
            String filePath = file.getPath();
            if (filePath.startsWith(rootDirPath)) {
                return filePath.equals(rootDirPath)
                        ? ""
                        : filePath.substring(rootDirPath.length() + 1);
            } else {
                throw new IllegalArgumentException();
            }
        } else {
            return file.getPath();
        }
    }

    /**
     * Read the binary file. The input file should be a zip file as written by
     * BinaryTestWriter. All three sections are reed, but only the string table
     * and test tree are parsed at this point. The test table is read from an
     * internal byte array as required.
     *
     * @param closeIfSuccess if set to <b>true</b>, binary file will be closed
     *                       after necessary integrity checks; if set to <b>false</b> binary file
     *                       remains opened (and probably locked by OS).
     */
    private void openBinaryFile(boolean closeIfSuccess) throws Fault {
        try {
            zipFileRead = false;
            if (jtdFile == null) {
                throw new Fault(i18n, "bin.noFile");
            }

            // open these all here to take the hit of exceptions
            // as early as possible
            File root = getRoot();
            File f = jtdFile.isAbsolute() || root == null ? jtdFile : new File(root, jtdFile.getPath());
            zipFile = new ZipFile(f);
            stringsEntry = zipFile.getEntry("strings");
            testsEntry = zipFile.getEntry("tests");
            treeEntry = zipFile.getEntry("tree");

            if (stringsEntry == null || testsEntry == null || treeEntry == null) {
                throw new Fault(i18n, "bin.badBinFile", zipFile.getName());
            }
            zipFileRead = true;
        } catch (FileNotFoundException e) {
            throw new Fault(i18n, "bin.cantFindFile", jtdFile.getPath());
        } catch (IOException e) {
            throw new Fault(i18n, "bin.ioError", jtdFile.getPath(), e);
        } finally {
            if (closeIfSuccess || !zipFileRead) {
                if (zipFile != null) {
                    try {
                        zipFile.close();
                    } catch (IOException e) {
                        throw new Fault(i18n, "bin.ioError", jtdFile.getPath(), e);
                    }
                }
                stringsEntry = null;
                testsEntry = null;
                treeEntry = null;
                zipFile = null;
            }
        }
    }

    /**
     * Read the binary file. The input file should be a zip file as written by
     * BinaryTestWriter. All three sections are read, but only the string table
     * and test tree are parsed at this point. The test table is read from an
     * internal byte array as required.
     */
    public synchronized void readBinaryFile() {
        if (zipFile == null && zipFileRead) {
            try {
                openBinaryFile(false);
            } catch (Fault ex) {
                localizedError(ex.getLocalizedMessage());
                zipFile = null;
            }
        }
        if (zipFile == null) {
            throw new IllegalStateException();
        }

        try {
            stringTable = StringTable.read(zipFile, stringsEntry);
            testTable = TestTable.read(zipFile, testsEntry, stringTable);
            testTree = TestTree.read(zipFile, treeEntry);
            zipFileRead = false;
        } catch (IOException e) {
            error(i18n, "bin.ioError", jtdFile.getPath(), e);
        } finally {
            try {
                zipFile.close();
            } catch (IOException e) {
                error(i18n, "bin.ioError", jtdFile.getPath(), e);
            }
            stringsEntry = null;
            testsEntry = null;
            treeEntry = null;
            zipFile = null;
        }
    }

    public StringTable getStringTable() {
        return stringTable;
    }

    public TestTable getTestTable() {
        return testTable;
    }

    public TestTree getTestTree() {
        return testTree;
    }

    @Override
    public Optional<Integer> totalNumberOfTestsInTheSuite() {
        return Optional.of(testTree.testsTotal);
    }

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

    /**
     * A StringTable is an array of strings. References to these strings
     * are written either as an index into the string array, or as zero
     * followed by the string inline.
     *
     * @see BinaryTestWriter.StringTable
     */
    static class StringTable {
        private String[] strings;

        /**
         * Read a string table from a stream.
         */
        StringTable(DataInputStream in) throws IOException {
            int count = readInt(in);
            strings = new String[count];
            for (int i = 0; i < count; i++) {
                strings[i] = in.readUTF();
            }
        }

        /**
         * Read a string table from a entry called "strings" in a zip file.
         */
        static StringTable read(ZipFile zf, ZipEntry ze) throws IOException {
            DataInputStream in = new DataInputStream(new BufferedInputStream(zf.getInputStream(ze)));
            return new StringTable(in);
        }

        /**
         * Read a reference to a string in the string table. It will either
         * be the index of an entry in the table, or 0 followed by the inline
         * string, if the string is not to be found in the table.
         */
        String readRef(DataInputStream in) throws IOException {
            int index = readInt(in);
            if (index == 0) {
                return in.readUTF();
            } else {
                return strings[index];
            }
        }

        /**
         * Get a string from the table.
         */
        String get(int i) {
            return strings[i];
        }
    }

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

    /**
     * A TestTable is a collection of TestDescriptions.
     */
    public static class TestTable {
        private ByteArrayInputStream bais;
        private StringTable stringTable;

        /**
         * Create a TestTable from a byte array as written by BinaryTestWriter.
         * The data of the entry is stored, and analyzed as required.
         */
        TestTable(byte[] data, StringTable stringTable) {
            bais = new ByteArrayInputStream(data);
            this.stringTable = stringTable;
        }

        /**
         * Read a test table from an entry called "tests" in a zip file.
         * The data of the entry is read and stored, but only analyzed
         * as required.
         */
        static TestTable read(ZipFile zf, ZipEntry ze, StringTable stringTable) throws IOException {
            byte[] bytes = new byte[(int) ze.getSize()];
            InputStream in = zf.getInputStream(ze);
            for (int total = 0; total < bytes.length; ) {
                total += in.read(bytes, total, bytes.length - total);
            }
            return new TestTable(bytes, stringTable);
        }

        /**
         * Get a test description from the table. It is reconstituted from the
         * data given when the table was created. The root and file for the
         * test description must be supplied separately; the index is a byte
         * offset within the data of where the test description data is to be found.
         * The test description data is encoded as a count, and then that many
         * name value pairs to be put in the test description.
         */
        TestDescription get(File root, File file, int index) throws IOException {
            bais.reset();
            bais.skip(index);
            Map<String, String> m = new HashMap<>();
            DataInputStream in = new DataInputStream(bais);
            int n = readInt(in);
            for (int i = 0; i < n; i++) {
                String key = stringTable.readRef(in);
                String value = stringTable.readRef(in);
                m.put(key, value);
            }
            return new TestDescription(root, file, m);
        }
    }

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

    /**
     * A TestTree represents a tree of test descriptions; the data of the individual
     * descriptions is stored in a test table.
     */
    public static class TestTree {
        private Node root;
        private final int testsTotal;

        /**
         * Create a test tree from a root node.
         * @param testsTotal total number of tests in this test tree
         */
        TestTree(Node root, int testsTotal) {
            this.root = root;
            this.testsTotal = testsTotal;
        }

        /**
         * Read a test tree from an entry called "tree" in a zip file.
         */
        static TestTree read(ZipFile zf, ZipEntry ze) throws IOException {
            DataInputStream in = new DataInputStream(new BufferedInputStream(zf.getInputStream(ze)));
            AtomicInteger testCounter = new AtomicInteger();
            Node root = new Node(in, testCounter);
            return new TestTree(root, testCounter.get());
        }

        /**
         * Get the node for a path within the tree.
         */
        public Node getNode(String path) {
            return path.isEmpty() ? root : root.getNode(path);
        }

        /**
         * A node within the test tree. It has a name, a set of indices that can be
         * used to find the test descriptions with the test table, and a set of child
         * nodes.
         */
        public static class Node {
            private String name;
            private int[] testIndexes;
            private Node[] children;

            /**
             * Read a node from an input stream. The node is represented as
             * a name, a count, followed by that many test indexes into the
             * corresponding test table, and a count, followed (recursively)
             * by that many child nodes.
             * @param testCounter counter of the total test number that is incremented along with test tree scan
             */
            Node(DataInputStream in, AtomicInteger testCounter) throws IOException {
                name = in.readUTF();
                int testCount = readInt(in);
                testCounter.addAndGet(testCount);
                if (testCount > 0) {
                    testIndexes = new int[testCount];
                    for (int i = 0; i < testIndexes.length; i++) {
                        testIndexes[i] = readInt(in);
                    }
                }
                int childCount = readInt(in);
                if (childCount > 0) {
                    children = new Node[childCount];
                    for (int i = 0; i < children.length; i++) {
                        children[i] = new Node(in, testCounter);
                    }
                }
            }

            /**
             * Get a child node as indicated by a relative path.
             */
            public Node getNode(String path) {
                int sep = path.indexOf(File.separatorChar);
                String head = sep == -1 ? path : path.substring(0, sep);
                for (Node child : children) {
                    if (child.name.equals(head)) {
                        return sep == -1 ? child : child.getNode(path.substring(sep + 1));
                    }
                }
                // not found
                return null;
            }

            public String getName() {
                return name;
            }

            public int[] getTestIndexes() {
                return testIndexes;
            }

            public Node[] getChildren() {
                return children;
            }

            /**
             * Get a TestDescription from this node, using the data in a
             * test table.
             */
            public TestDescription getTest(int index, File root, File path, TestTable testTable) throws IOException {
                if (testIndexes == null || index > testIndexes.length) {
                    throw new IllegalArgumentException();
                }
                return testTable.get(root, path, testIndexes[index]);
            }
        }
    }
}



