/*
 *    GeoTools - OpenSource mapping toolkit
 *    http://geotools.org
 *    (C) 2003-2006, Geotools Project Managment Committee (PMC)
 *
 *    This library is free software; you can redistribute it and/or
 *    modify it under the terms of the GNU Lesser General Public
 *    License as published by the Free Software Foundation; either
 *    version 2.1 of the License, or (at your option) any later version.
 *
 *    This library 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
 *    Lesser General Public License for more details.
 */
package org.geotools.resources;

// J2SE dependencies
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Logger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;


/**
 * Provides access to {@code test-data} directories associated with JUnit tests.
 * <p>
 * We have chosen "{@code test-data}" to follow the javadoc "{@code doc-files}" convention
 * of ensuring that data directories don't look anything like normal java packages.
 * <p>
 * Example:
 * <pre>
 * class MyClass {
 *     public void example() {
 *         Image testImage = new ImageIcon(TestData.url(this, "test.png")).getImage();
 *         Reader reader = TestData.openReader(this, "script.xml");
 *         // ... do some process
 *         reader.close();
 *     }
 * }
 * </pre>
 * Where the directory structure goes as bellow:
 * <ul>
 *   <li>{@code MyClass.java}<li>
 *   <li>{@code test-data/test.png}</li>
 *   <li>{@code test-data/script.xml}</li>
 * </ul>
 * <p>
 * By convention you should try and locate {@code test-data} near the JUnit test
 * cases that uses it. If you need an access to shared test data, import the
 * {@link org.geotools.TestData} class from the {@code sample-module} instead
 * of this one.
 *
 * @since 2.0
 * @source $URL: http://svn.geotools.org/geotools/tags/2.4.1/modules/library/metadata/src/main/java/org/geotools/resources/TestData.java $
 * @version $Id: TestData.java 24085 2007-01-27 21:19:05Z desruisseaux $
 * @author James McGill
 * @author Simone Giannecchiin (simboss)
 * @author Martin Desruisseaux
 *
 * @tutorial http://www.geotools.org/display/GEOT/5.8+Test+Data
 *
 * @deprecated Moved to {@link org.geotools.test.TestData}.
 */
public class TestData implements Runnable {
    /**
     * The test data directory.
     */
    private static final String DIRECTORY = "test-data";

    /**
     * Encoding of URL path.
     */
    private static final String ENCODING = "UTF-8";

    /**
     * The {@linkplain System#getProperty(String) system property} key for more extensive test
     * suite. The value for this key is returned by the {@link #isExtensiveTest} method. Some
     * test suites will perform more extensive test coverage if this property is set to
     * {@code true}. The value for this property is typically defined on the command line as a
     * <code>-D{@value}=true</code> option at Java or Maven starting time.
     */
    public static final String EXTENSIVE_TEST_KEY = "org.geotools.test.extensive";

    /**
     * The {@linkplain System#getProperty(String) system property} key for interactive tests. 
     * The value for this key is returned by the {@link #isInteractiveTest} method. Some
     * test suites will show windows with maps and other artifacts related to testing 
     * if this property is set to {@code true}. 
     * The value for this property is typically defined on the command line as a
     * <code>-D{@value}=true</code> option at Java or Maven starting time.
     */
    public static final String INTERACTIVE_TEST_KEY = "org.geotools.test.interactive";

    /**
     * The files to delete at shutdown time. {@link File#deleteOnExit} alone doesn't seem
     * suffisient since it will preserve any overwritten files.
     */
    private static final LinkedList toDelete = new LinkedList();

    /**
     * Register the thread to be automatically executed at shutdown time.
     * This thread will delete all temporary files registered in {@link #toDelete}.
     */
    static {
        Runtime.getRuntime().addShutdownHook(new Thread(new TestData(), "Test data cleaner"));
    }

    /**
     * Do not allow instantiation of this class, except for extending it.
     */
    protected TestData() {
    }

    /**
     * Get a property as a boolean value. If the property can't be
     * fetch for security reason, then default to {@code false}.
     */
    private static boolean getBoolean(final String name) {
        try {
            return Boolean.getBoolean(name);
        } catch (SecurityException exception) {
            Logger.getLogger("org.geotools").warning(exception.getLocalizedMessage());
            return false;
        }
    }

    /**
     * Returns {@code true} if the running Java virtual machine is 1.4. This is the lowest
     * Java version currently supported by Geotools. This version will increase in future
     * Geotools version.
     */
    public static boolean isBaseJavaPlatform() {
        return System.getProperty("java.version").startsWith("1.4");
    }

    /**
     * Returns {@code true} if {@value #EXTENSIVE_TEST_KEY} system property is set to
     * {@code true}. Test suites should check this value before to perform lengthly tests.
     */
    public static boolean isExtensiveTest() {
        return getBoolean(EXTENSIVE_TEST_KEY);
    }

    /**
     * Returns {@code true} if {@value #INTERACTIVE_TEST_KEY} system property is set to {@code true}.
     * Test suites should check this value before showing any kind of graphical window to the user.
     */
    public static boolean isInteractiveTest() {
        return getBoolean(INTERACTIVE_TEST_KEY);
    }

    /**
     * Locates named test-data resource for caller. <strong>Note:</strong> Consider using the
     * <code>{@link #url url}(caller, name)</code> method instead if the resource should always
     * exists.
     *
     * @param  caller Calling class or object used to locate {@code test-data}.
     * @param  name resource name in {@code test-data} directory.
     * @return URL or {@code null} if the named test-data could not be found.
     *
     * @see #url
     */
    public static URL getResource(final Object caller, String name) {
        if (name == null || (name=name.trim()).length() == 0) {
            name = DIRECTORY;
        } else {
            name = DIRECTORY + '/' + name;
        }
        if (caller != null) {
            final Class c = (caller instanceof Class) ? (Class) caller : caller.getClass();
            return c.getResource(name);
        } else {
            return Thread.currentThread().getContextClassLoader().getResource(name);
        }
    }

    /**
     * Access to <code>{@linkplain #getResource getResource}(caller, path)</code> as a non-null
     * {@link URL}. At the difference of {@code getResource}, this method throws an exception if
     * the resource is not found. This provides a more explicit explanation about the failure
     * reason than the infamous {@link NullPointerException}.
     *
     * @param  caller Calling class or object used to locate {@code test-data}.
     * @param  path Path to file in {@code test-data}.
     * @return The URL to the {@code test-data} resource.
     * @throws FileNotFoundException if the resource is not found.
     *
     * @since 2.2
     */
    public static URL url(final Object caller, final String path) throws FileNotFoundException {
        final URL url = getResource(caller, path);
        if (url == null) {
            throw new FileNotFoundException("Could not locate test-data: " + path);
        }
        return url;
    }

    /**
     * Access to <code>{@linkplain #getResource getResource}(caller, path)</code> as a non-null
     * {@link File}. You can access the {@code test-data} directory with:
     *
     * <blockquote><pre>
     * TestData.file(MyClass.class, null);
     * </pre></blockquote>
     *
     * @param  caller Calling class or object used to locate {@code test-data}.
     * @param  path Path to file in {@code test-data}.
     * @return The file to the {@code test-data} resource.
     * @throws FileNotFoundException if the file is not found.
     * @throws IOException if the resource can't be fetched for an other reason.
     */
    public static File file(final Object caller, final String path) throws IOException {
        final URL url = url(caller, path);
        final File file = new File(URLDecoder.decode(url.getPath(), ENCODING));
        if (!file.exists()) {
            throw new FileNotFoundException("Could not locate test-data: " + path);
        }
        return file;
    }

    /**
     * Creates a temporary file with the given name. The file will be created in the
     * {@code test-data} directory and will be deleted on exit.
     *
     * @param  caller Calling class or object used to locate {@code test-data}.
     * @param  A base name for the temporary file.
     * @return The temporary file in the {@code test-data} directory.
     * @throws IOException if the file can't be created.
     */
    public static File temp(final Object caller, final String name) throws IOException {
        final File testData = file(caller, null);
        final int split = name.lastIndexOf('.');
        final String prefix = (split < 0) ? name  : name.substring(0,split);
        final String suffix = (split < 0) ? "tmp" : name.substring(split+1);
        final File tmp = File.createTempFile(prefix, '.'+suffix, testData);
        deleteOnExit(tmp);
        return tmp;
    }

    /**
     * Provides a non-null {@link InputStream} for named test data.
     * It is the caller responsability to close this stream after usage.
     *
     * @param  caller Calling class or object used to locate {@code test-data}.
     * @param  name of test data to load.
     * @return The input stream.
     * @throws FileNotFoundException if the resource is not found.
     * @throws IOException if an error occurs during an input operation.
     *
     * @since 2.2
     */
    public static InputStream openStream(final Object caller, final String name)
            throws IOException
    {
        return new BufferedInputStream(url(caller, name).openStream());
    }

    /**
     * Provides a {@link BufferedReader} for named test data. The buffered reader is provided as
     * an {@link LineNumberReader} instance, which is useful for displaying line numbers where
     * error occur. It is the caller responsability to close this reader after usage.
     *
     * @param  caller The class of the object associated with named data.
     * @param  name of test data to load.
     * @return The buffered reader.
     * @throws FileNotFoundException if the resource is not found.
     * @throws IOException if an error occurs during an input operation.
     *
     * @since 2.2
     */
    public static LineNumberReader openReader(final Object caller, final String name)
            throws IOException
    {
        return new LineNumberReader(new InputStreamReader(url(caller, name).openStream()));
    }

    /**
     * Provides a {@link java.io.BufferedReader} for named test data.
     * It is the caller responsability to close this reader after usage.
     *
     * @param  caller The class of the object associated with named data.
     * @param  name of test data to load.
     * @return The reader, or {@code null} if the named test data are not found.
     * @throws IOException if an error occurs during an input operation.
     *
     * @deprecated Use {@link #openReader} instead. The {@code openReader} method throws an
     *  exception if the resource is not found, instead of returning null. This make debugging
     *  easier, since it replaces infamous {@link NullPointerException} by a more explicit error
     *  message during tests. Furthermore, the {@code openReader} name make it more obvious that
     *  the stream is not closed automatically and is also consistent with other method names in
     *  this class.
     */
    public static BufferedReader getReader(final Object caller, final String name)
            throws IOException
    {
        final URL url = getResource(caller, name);
        if (url == null) {
            return null; // echo handling of getResource( ... )
        }
        return new BufferedReader(new InputStreamReader(url.openStream()));
    }

    /**
     * Provides a channel for named test data. It is the caller responsability to close this
     * chanel after usage.
     *
     * @param  caller The class of the object associated with named data.
     * @param  name of test data to load.
     * @return The chanel.
     * @throws FileNotFoundException if the resource is not found.
     * @throws IOException if an error occurs during an input operation.
     *
     * @since 2.2
     */
    public static ReadableByteChannel openChannel(final Object caller, final String name)
            throws IOException
    {
        final URL url = url(caller, name);
        final File file = new File(URLDecoder.decode(url.getPath(), ENCODING));
        if (file.exists()) {
            return new RandomAccessFile(file, "r").getChannel();
        }
        return Channels.newChannel(url.openStream());
    }

    /**
     * Unzip a file in the {@code test-data} directory. The zip file content is inflated in place,
     * i.e. inflated files are written in the same {@code test-data} directory. If a file to be
     * inflated already exists in the {@code test-data} directory, then the existing file is left
     * untouched and the corresponding ZIP entry is silently skipped. This approach avoid the
     * overhead of inflating the same files many time if this {@code unzipFile} method is invoked
     * before every tests.
     * <p>
     * Inflated files will be automatically {@linkplain File#deleteOnExit deleted on exit}
     * if and only if they have been modified. Callers don't need to worry about cleanup,
     * because the files are inflated in the {@code target/.../test-data} directory, which
     * is not versionned by SVN and is cleaned by Maven on {@code mvn clean} execution.
     *
     * @param  caller The class of the object associated with named data.
     * @param  name The file name to unzip in place.
     * @throws FileNotFoundException if the specified zip file is not found.
     * @throws IOException if an error occurs during an input or output operation.
     *
     * @since 2.2
     */
    public static void unzipFile(final Object caller, final String name) throws IOException {
        final File        file    = file(caller, name);
        final File        parent  = file.getParentFile().getAbsoluteFile();
        final ZipFile     zipFile = new ZipFile(file);
        final Enumeration entries = zipFile.entries();
        final byte[]      buffer  = new byte[4096];
        while (entries.hasMoreElements()) {
            final ZipEntry entry = (ZipEntry) entries.nextElement();
            if (entry.isDirectory()) {
                continue;
            }
            final File path = new File(parent, entry.getName());
            if (path.exists()) {
                continue;
            }
            final File directory = path.getParentFile();
            if (directory != null && !directory.exists()) {
                directory.mkdirs();
            }
            // Copy the file. Note: no need for a BufferedOutputStream,
            // since we are already using a buffer of type byte[4096].
            final InputStream  in  = zipFile.getInputStream(entry);
            final OutputStream out = new FileOutputStream(path);
            int len;
            while ((len = in.read(buffer)) >= 0) {
                out.write(buffer, 0, len);
            }
            out.close();
            in.close();
            // Call 'deleteOnExit' only after after we closed the file,
            // because this method will save the modification time.
            deleteOnExit(path, false);
        }
        zipFile.close();
    }

    /**
     * Requests that the file or directory denoted by the specified
     * pathname be deleted when the virtual machine terminates.
     */
    protected static void deleteOnExit(final File file) {
        deleteOnExit(file, true);
    }

    /**
     * Requests that the file or directory denoted by the specified pathname be deleted
     * when the virtual machine terminates. This method can optionnaly delete the file
     * only if it has been modified, thus giving a chance for test suites to copy their
     * resources only once.
     * 
     * @param file The file to delete.
     * @param force If {@code true}, delete the file in all cases. If {@code false},
     *        delete the file if and only if it has been modified. The default value
     *        if {@code true}.
     *
     * @since 2.4
     */
    protected static void deleteOnExit(final File file, final boolean force) {
        if (force) {
            file.deleteOnExit();
        }
        final Deletable entry = new Deletable(file, force);
        synchronized (toDelete) {
            if (file.isFile()) {
                toDelete.addFirst(entry);
            } else {
                toDelete.addLast(entry);
            }
        }
    }

    /**
     * A file that may be deleted on JVM shutdown.
     */
    private static final class Deletable {
        /**
         * The file to delete.
         */
        private final File file;

        /**
         * The initial timestamp. Used in order to determine if the file has been modified.
         */
        private final long timestamp;

        /**
         * Constructs an entry for a file to be deleted.
         */
        public Deletable(final File file, final boolean force) {
            this.file = file;
            timestamp = force ? Long.MIN_VALUE : file.lastModified();
        }

        /**
         * Returns {@code true} if failure to delete this file can be ignored.
         */
        public boolean canIgnore() {
            return timestamp != Long.MIN_VALUE && file.isDirectory();
        }

        /**
         * Deletes this file, if modified. Returns {@code false} only
         * if the file should be deleted but the operation failed.
         */
        public boolean delete() {
            if (!file.exists() || file.lastModified() <= timestamp) {
                return true;
            }
            return file.delete();
        }

        /**
         * Returns the filepath.
         */
        public String toString() {
            return String.valueOf(file);
        }
    }

    /**
     * Deletes all temporary files. This method is invoked automatically at shutdown time and
     * should not be invoked directly. It is public only as an implementation side effect.
     */
    public void run() {
        int iteration = 5; // Maximum number of iterations
        synchronized (toDelete) {
            while (!toDelete.isEmpty()) {
                if (--iteration < 0) {
                    break;
                }
                /*
                 * Before to try to delete the files, invokes the finalizers in a hope to close
                 * any input streams that the user didn't explicitly closed. Leaving streams open
                 * seems to occurs way too often in our test suite...
                 */
                System.gc();
                System.runFinalization();
                for (final Iterator it=toDelete.iterator(); it.hasNext();) {
                    final Deletable f = (Deletable) it.next();
                    try {
                        if (f.delete()) {
                            it.remove();
                            continue;
                        }
                    } catch (SecurityException e) {
                        if (iteration == 0) {
                            System.err.print(Utilities.getShortClassName(e));
                            System.err.print(": ");
                        }
                    }
                    // Can't use logging, since logger are not available anymore at shutdown time.
                    if (iteration == 0 && !f.canIgnore()) {
                        System.err.print("Can't delete ");
                        System.err.println(f);
                    }
                }
            }
        }
    }
}
