/*
 *    GeoTools - OpenSource mapping toolkit
 *    http://geotools.org
 *    (C) 2003-2006, Geotools Project Managment Committee (PMC)
 *    (C) 2001, Institut de Recherche pour le Développement
 *
 *    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;

import java.io.*;
import java.util.*;
import java.text.MessageFormat;
import java.lang.reflect.Field;


/**
 * Resource compiler. This class is run from the command line at compile time only.
 * {@code IndexedResourceCompiler} scans for {@code .properties} files and copies their content
 * to {@code .utf} files using UTF8 encoding. It also checks for key validity and checks values
 * for {@link MessageFormat} compatibility. Finally, it creates a {@code FooKeys.java} source
 * file declaring resource keys as integer constants.
 * <p>
 * This class <strong>must</strong> be run from the maven root of Geotools project.
 * <p>
 * {@code IndexedResourceCompiler} and all {@code FooKeys} classes don't need to be included in the
 * final JAR file. They are used at compile time only and no other classes should keep reference to
 * them.
 *
 * @since 2.4
 * @source $URL: http://svn.geotools.org/geotools/tags/2.4.1/modules/library/metadata/src/main/java/org/geotools/resources/IndexedResourceCompiler.java $
 * @version $Id: IndexedResourceCompiler.java 26165 2007-07-06 17:02:26Z desruisseaux $
 * @author Martin Desruisseaux
 */
public final class IndexedResourceCompiler implements Comparator {
    /**
     * The base directory for {@code "java"} {@code "resources"} sub-directories.
     * The directory structure must be consistent with Maven conventions.
     *
     * @see #sourceDirectory
     */
    private static final File SOURCE_DIRECTORY = new File("modules/library/metadata/src/main");

    /**
     * The resources to process.
     */
    private static final Class[] RESOURCES_TO_PROCESS = {
        org.geotools.resources.i18n.Descriptions.class,
        org.geotools.resources.i18n.Vocabulary  .class,
        org.geotools.resources.i18n.Logging     .class,
        org.geotools.resources.i18n.Errors      .class
    };

    /**
     * Extension for properties source files.
     * Must be in the {@code ${sourceDirectory}/java} directory.
     */
    private static final String PROPERTIES_EXT = ".properties";

    /**
     * Extension for resource target files.
     * Will be be in the {@code ${sourceDirectory}/resources} directory.
     */
    private static final String RESOURCES_EXT = ".utf";

    /**
     * Prefix for argument count in resource key names. For example, a resource
     * expecting one argument may have a key name like "HELLO_$1".
     */
    private static final String ARGUMENT_COUNT_PREFIX = "_$";

    /**
     * The maximal length of comment lines.
     */
    private static final int COMMENT_LENGTH = 92;

    /**
     * The base directory for {@code "java"} {@code "resources"} sub-directories.
     * The directory structure must be consistent with Maven conventions.
     */
    private final File sourceDirectory;

    /**
     * Integer IDs allocated to resource keys. This map will be shared for all languages
     * of a given resource bundle.
     */
    private final Map/*<Integer,String>*/ allocatedIDs = new HashMap();

    /**
     * Resource keys and their localized values. This map will be cleared for each language
     * in a resource bundle.
     */
    private final Map/*<String,String>*/ resources = new HashMap();

    /**
     * The output stream for printing message.
     */
    private final PrintWriter out;

    /**
     * Constructs a new {@code IndexedResourceCompiler}. This method will immediately look for
     * a {@code FooKeys.class} file. If one is found, integer keys are loaded in order to reuse
     * the same values.
     *
     * @param  sourceDirectory The base directory for {@code "java"} {@code "resources"}
     *         sub-directories. The directory structure must be consistent with Maven conventions.
     * @param  bundleClass The resource bundle base class
     *         (e.g. <code>{@linkplain org.geotools.resources.i18n.Vocabulary}.class}</code>).
     * @param  renumber {@code true} for renumbering all key values.
     * @param  out The output stream for printing message.
     * @throws IOException if an input/output operation failed.
     */
    private IndexedResourceCompiler(final File sourceDirectory, final Class bundleClass,
                                    final boolean renumber, final PrintWriter out)
            throws IOException
    {
        this.sourceDirectory = sourceDirectory;
        this.out = out;
        if (!renumber) try {
            final String classname = toKeyClass(bundleClass.getName());
            final Field[] fields = Class.forName(classname).getFields();
            out.print("Loading ");
            out.println(classname);
            /*
             * Copies all fields into {@link #allocatedIDs} map.
             */
            Field.setAccessible(fields, true);
            for (int i=fields.length; --i>=0;) {
                final Field field = fields[i];
                final String  key = field.getName();
                try {
                    final Object ID = field.get(null);
                    if (ID instanceof Integer) {
                        allocatedIDs.put((Integer)ID, key);
                    }
                } catch (IllegalAccessException exception) {
                    final File source = new File(classname.replace('.','/') + ".class");
                    warning(source, key, "Access denied", exception);
                }
            }
        } catch (ClassNotFoundException exception) {
            /*
             * 'VocabularyKeys.class' doesn't exist. This is okay (probably normal).
             * We will create 'VocabularyKeys.java' later using automatic key values.
             */
        }
    }

    /**
     * Returns the class name for the keys. For example if {@code bundleClass} is
     * {@code "org.geotools.resources.i18n.Vocabulary"}, then this method returns
     * {@code "org.geotools.resources.i18n.VocabularyKeys"}.
     */
    private static String toKeyClass(String bundleClass) {
        if (bundleClass.endsWith("s")) {
            bundleClass = bundleClass.substring(0, bundleClass.length()-1);
        }
        return bundleClass + "Keys";
    }

    /**
     * Load the specified property file.
     */
    private static Properties loadPropertyFile(final File file) throws IOException {
        final InputStream input = new FileInputStream(file);
        final Properties properties = new Properties();
        properties.load(input);
        input.close();
        return properties;
    }

    /**
     * Loads all properties from a {@code .properties} file. Resource keys are checked for naming
     * conventions (i.e. resources expecting some arguments must have a key name ending with
     * {@code "_$n"} where {@code "n"} is the number of arguments). This method transforms resource
     * values into legal {@link MessageFormat} patterns when necessary.
     *
     * @param  file The properties file to read.
     * @throws IOException if an input/output operation failed.
     */
    private void processPropertyFile(final File file) throws IOException {
        final Properties properties = loadPropertyFile(file);
        resources.clear();
        for (final Iterator it=properties.entrySet().iterator(); it.hasNext();) {
            final Map.Entry entry = (Map.Entry) it.next();
            final String key      = (String) entry.getKey();
            final String value    = (String) entry.getValue();
            /*
             * Checks key and value validity.
             */
            if (key.trim().length() == 0) {
                warning(file, key, "Empty key.", null);
                continue;
            }
            if (value.trim().length() == 0) {
                warning(file, key, "Empty value.", null);
                continue;
            }
            /*
             * Checks if the resource value is a legal MessageFormat pattern.
             */
            final MessageFormat message;
            try {
                message = new MessageFormat(toMessageFormatString(value));
            } catch (IllegalArgumentException exception) {
                warning(file, key, "Bad resource value", exception);
                continue;
            }
            /*
             * Checks if the expected arguments count (according to naming conventions)
             * matches the arguments count found in the MessageFormat pattern.
             */
            final int argumentCount;
            final int index = key.lastIndexOf(ARGUMENT_COUNT_PREFIX);
            if (index < 0) {
                argumentCount = 0;
                resources.put(key, value); // Text will not be formatted using MessageFormat.
            } else try {
                String suffix = key.substring(index + ARGUMENT_COUNT_PREFIX.length());
                argumentCount = Integer.parseInt(suffix);
                resources.put(key, message.toPattern());
            } catch (NumberFormatException exception) {
                warning(file, key, "Bad number in resource key", exception);
                continue;
            }
            final int expected = message.getFormats().length;
            if (argumentCount != expected) {
                final String suffix = ARGUMENT_COUNT_PREFIX + expected;
                warning(file, key, "Key name should ends with \""+suffix+"\".", null);
                continue;
            }
        }
        /*
         * Allocates an ID for each new key.
         */
        final String[] keys = (String[]) resources.keySet().toArray(new String[resources.size()]);
        Arrays.sort(keys, this);
        int freeID = 0;
        for (int i=0; i<keys.length; i++) {
            final String key = keys[i];
            if (!allocatedIDs.containsValue(key)) {
                Integer ID;
                do {
                    ID = new Integer(freeID++);
                } while (allocatedIDs.containsKey(ID));
                allocatedIDs.put(ID, key);
            }
        }
    }

    /**
     * Write UTF file. Method {@link #processPropertyFile} should be invoked beforehand to
     * {@code writeUTFFile}.
     *
     * @param  file The destination file.
     * @throws IOException if an input/output operation failed.
     */
    private void writeUTFFile(final File file) throws IOException {
        final int count = allocatedIDs.isEmpty() ? 0 : ((Integer) Collections.max(allocatedIDs.keySet())).intValue()+1;
        final DataOutputStream out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(file)));
        out.writeInt(count);
        for (int i=0; i<count; i++) {
            final String value = (String) resources.get(allocatedIDs.get(new Integer(i)));
            out.writeUTF((value!=null) ? value : "");
        }
        out.close();
    }

    /**
     * Changes a "normal" text string into a pattern compatible with {@link MessageFormat}.
     * The main operation consists of changing ' for '', except for '{' and '}' strings.
     */
    private static String toMessageFormatString(final String text) {
        int level =  0;
        int last  = -1;
        final StringBuffer buffer = new StringBuffer(text);
search: for (int i=0; i<buffer.length(); i++) { // Length of 'buffer' will vary.
            switch (buffer.charAt(i)) {
                /*
                 * Left and right braces take us up or down a level.  Quotes will only be doubled
                 * if we are at level 0.  If the brace is between quotes it will not be taken into
                 * account as it will have been skipped over during the previous pass through the
                 * loop.
                 */
                case '{' : level++; last=i; break;
                case '}' : level--; last=i; break;
                case '\'': {
                    /*
                     * If a brace ('{' or '}') is found between quotes, the entire block is
                     * ignored and we continue with the character following the closing quote.
                     */
                    if (i+2<buffer.length() && buffer.charAt(i+2)=='\'') {
                        switch (buffer.charAt(i+1)) {
                            case '{': i+=2; continue search;
                            case '}': i+=2; continue search;
                        }
                    }
                    if (level <= 0) {
                        /*
                         * If we weren't between braces, we must double the quotes.
                         */
                        buffer.insert(i++, '\'');
                        continue search;
                    }
                    /*
                     * If we find ourselves between braces, we don't normally need to double
                     * our quotes.  However, the format {0,choice,...} is an exception.
                     */
                    if (last>=0 && buffer.charAt(last)=='{') {
                        int scan=last;
                        do if (scan>=i) continue search;
                        while (Character.isDigit(buffer.charAt(++scan)));
                        final String choice=",choice,";
                        final int end=scan+choice.length();
                        if (end<buffer.length() && buffer.substring(scan, end).equalsIgnoreCase(choice)) {
                            buffer.insert(i++, '\'');
                            continue search;
                        }
                    }
                }
            }
        }
        return buffer.toString();
    }

    /**
     * Prints a message to the output stream.
     *
     * @param file      File that produced the error, or {@code null} if none.
     * @param key       Resource key that produced the error, or {@code null} if none.
     * @param message   The message string.
     * @param exception An optional exception that is the cause of this warning.
     */
    private void warning(final File file,      final String key,
                         final String message, final Exception exception)
    {
        out.print("ERROR ");
        if (file != null) {
            String filename = file.getPath();
            if (filename.endsWith(PROPERTIES_EXT)) {
                filename = filename.substring(0, filename.length()-PROPERTIES_EXT.length());
            }
            out.print('(');
            out.print(filename);
            out.print(')');
        }
        out.print(": ");
        if (key != null) {
            out.print('"');
            out.print(key);
            out.print('"');
        }
        out.println();
        out.print(message);
        if (exception != null) {
            out.print(": ");
            out.print(exception.getLocalizedMessage());
        }
        out.println();
        out.println();
        out.flush();
    }

    /**
     * Creates a source file for resource keys.
     *
     * @param  bundleClass The resource bundle base class
     *         (e.g. <code>{@linkplain org.geotools.resources.i18n.Vocabulary}.class}</code>).
     * @throws IOException if an input/output operation failed.
     */
    private void writeJavaSource(final Class bundleClass) throws IOException {
        final String fullname    = toKeyClass(bundleClass.getName());
        final int    packageEnd  = fullname.lastIndexOf('.');
        final String packageName = fullname.substring(0, packageEnd);
        final String classname   = fullname.substring(packageEnd + 1);
        final File   file        = new File(sourceDirectory,
                "java/" + fullname.replace('.', '/') + ".java");
        if (!file.getParentFile().isDirectory()) {
            warning(file, null, "Parent directory not found.", null);
            return;
        }
        final BufferedWriter out = new BufferedWriter(new OutputStreamWriter(
                new FileOutputStream(file), "UTF-8"));
        out.write("/*\n" +
                  " *    GeoTools - OpenSource mapping toolkit\n" +
                  " *    http://geotools.org\n" +
                  " *    (C) 2003-2007, Geotools Project Managment Committee (PMC)\n" +
                  " *    \n" +
                  " *    This library is free software; you can redistribute it and/or\n" +
                  " *    modify it under the terms of the GNU Lesser General Public\n" +
                  " *    License as published by the Free Software Foundation; either\n" +
                  " *    version 2.1 of the License, or (at your option) any later version.\n" +
                  " *    \n" +
                  " *    This library is distributed in the hope that it will be useful,\n" +
                  " *    but WITHOUT ANY WARRANTY; without even the implied warranty of\n" +
                  " *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU\n" +
                  " *    Lesser General Public License for more details.\n" +
                  " *    \n" +
                  " *    THIS IS AN AUTOMATICALLY GENERATED FILE. DO NOT EDIT!\n"   +
                  " *    Generated with: org.geotools.resources.IndexedResourceCompiler\n" +
                  " */\n");
        out.write("package ");
        out.write(packageName);
        out.write(";\n\n\n");
        out.write("/**\n"                                                                  +
                  " * Resource keys. This class is used when compiling sources, but\n"     +
                  " * no dependencies to {@code ResourceKeys} should appear in any\n"      +
                  " * resulting class files.  Since Java compiler inlines final integer\n" +
                  " * values, using long identifiers will not bloat constant pools of\n"   +
                  " * classes compiled against the interface, provided that no class\n"    +
                  " * implements this interface.\n"                                        +
                  " *\n"                                                                   +
                  " * @see org.geotools.resources.IndexedResourceBundle\n"                 +
                  " * @see org.geotools.resources.IndexedResourceCompiler\n"               +
                  " * @source \u0024URL\u0024\n"                                           +
                  " */\n");
        out.write("public final class "); out.write(classname); out.write(" {\n");
        out.write("    private "); out.write(classname); out.write("() {\n");
        out.write("    }\n");
        final Map.Entry[] entries = (Map.Entry[]) allocatedIDs.entrySet().toArray(new Map.Entry[allocatedIDs.size()]);
        Arrays.sort(entries, this);
        for (int i=0; i<entries.length; i++) {
            out.write('\n');
            final String key = (String) entries[i].getValue();
            final String ID  = entries[i].getKey().toString();
            String message = (String) resources.get(key);
            if (message != null) {
                out.write("    /**\n");
                while (((message=message.trim()).length()) != 0) {
                    out.write("     * ");
                    int stop = message.length();
                    if (stop > COMMENT_LENGTH) {
                        stop = COMMENT_LENGTH;
                        while (stop>20 && !Character.isWhitespace(message.charAt(stop))) {
                            stop--;
                        }
                    }
                    out.write(message.substring(0, stop).trim());
                    out.write('\n');
                    message = message.substring(stop);
                }
                out.write("     */\n");
            }
            out.write("    public static final int ");
            out.write(key);
            out.write(" = ");
            out.write(ID);
            out.write(";\n");
        }
        out.write("}\n");
        out.close();
    }

    /**
     * Compares two resource keys. Object {@code o1} and {@code o2} are usually {@link String}
     * objects representing resource keys (for example, "{@code MISMATCHED_DIMENSION}"), but
     * may also be {@link java.util.Map.Entry}.
     */
    public int compare(Object o1, Object o2) {
        if (o1 instanceof Map.Entry) o1 = ((Map.Entry) o1).getValue();
        if (o2 instanceof Map.Entry) o2 = ((Map.Entry) o2).getValue();
        final String key1 = (String) o1;
        final String key2 = (String) o2;
        return key1.compareTo(key2);
    }

    /**
     * Scans the package for resources.
     *
     * @param  sourceDirectory The base directory for {@code "java"} {@code "resources"}
     *         sub-directories. The directory structure must be consistent with Maven conventions.
     * @param  bundleClass The resource bundle base class
     *         (e.g. <code>{@linkplain org.geotools.resources.i18n.Vocabulary}.class}</code>).
     * @param  renumber {@code true} for renumbering all key values.
     * @param  out The output stream for printing message.
     * @throws IOException if an input/output operation failed.
     */
    private static void scanForResources(final File sourceDirectory, final Class bundleClass,
                                         final boolean renumber, final PrintWriter out)
            throws IOException
    {
        final String fullname    = bundleClass.getName();
        final int    packageEnd  = fullname.lastIndexOf('.');
        final String packageName = fullname.substring(0, packageEnd);
        final String classname   = fullname.substring(packageEnd + 1);
        final String packageDir  = packageName.replace('.', '/');
        final File   srcDir      = new File(sourceDirectory, "java/"      + packageDir);
        final File   utfDir      = new File(sourceDirectory, "resources/" + packageDir);
        if (!srcDir.isDirectory()) {
            out.print('"');
            out.print(srcDir.getPath());
            out.println("\" is not a directory.");
            return;
        }
        if (!utfDir.isDirectory()) {
            out.print('"');
            out.print(utfDir.getPath());
            out.println("\" is not a directory.");
            return;
        }
        IndexedResourceCompiler compiler = null;
        final File[] content = srcDir.listFiles();
        File defaultLanguage = null;
        for (int i=0; i<content.length; i++) {
            final File file = content[i];
            final String filename = file.getName();
            if (filename.startsWith(classname) && filename.endsWith(PROPERTIES_EXT)) {
                if (compiler == null) {
                    compiler = new IndexedResourceCompiler(sourceDirectory, bundleClass, renumber, out);
                }
                compiler.processPropertyFile(file);
                final String noExt = filename.substring(0, filename.length() - PROPERTIES_EXT.length());
                final File utfFile = new File(utfDir, noExt + RESOURCES_EXT);
                compiler.writeUTFFile(utfFile);
                if (noExt.equals(classname)) {
                    defaultLanguage = file;
                }
            }
        }
        if (compiler != null) {
            if (defaultLanguage != null) {
                compiler.resources.clear();
                compiler.resources.putAll(loadPropertyFile(defaultLanguage));
            }
            compiler.writeJavaSource(bundleClass);
        }
    }

    /**
     * Run the resource compiler.
     *
     * @param args The command-line arguments.
     * @param  sourceDirectory The base directory for {@code "java"} {@code "resources"}
     *         sub-directories. The directory structure must be consistent with Maven conventions.
     * @param  resourcesToProcess The resource bundle base classes
     *         (e.g. <code>{@linkplain org.geotools.resources.i18n.Vocabulary}.class}</code>).
     */
    public static void main(String[] args, final File sourceDirectory, final Class[] resourcesToProcess) {
        final Arguments arguments = new Arguments(args);
        final boolean renumber = arguments.getFlag("-renumber");
        final PrintWriter out = arguments.out;
        args = arguments.getRemainingArguments(0);
        if (!sourceDirectory.isDirectory()) {
            out.print(sourceDirectory);
            out.println(" not found or is not a directory.");
            return;
        }
        for (int i=0; i<resourcesToProcess.length; i++) {
            try {
                scanForResources(sourceDirectory, resourcesToProcess[i], renumber, out);
            } catch (IOException exception) {
                out.println(exception.getLocalizedMessage());
            }
        }
        out.flush();
    }

    /**
     * Run the compiler for GeoTools resources.
     */
    public static void main(final String[] args) {
        main(args, SOURCE_DIRECTORY, RESOURCES_TO_PROCESS);
    }
}
