/*
 *    GeoTools - The Open Source Java GIS Toolkit
 *    http://geotools.org
 *
 *    (C) 2001-2008, Open Source Geospatial Foundation (OSGeo)
 *
 *    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;
 *    version 2.1 of the License.
 *
 *    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.BufferedInputStream;
import java.io.DataInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.Writer;
import java.text.MessageFormat;
import java.util.Enumeration;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.NoSuchElementException;
import java.util.ResourceBundle;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;

import org.geotools.util.Utilities;
import org.geotools.util.logging.Logging;
import org.opengis.util.InternationalString;


/**
 * {@link ResourceBundle} implementation using integers instead of strings for resource
 * keys. Because it doesn't use strings, this implementation avoids adding all those string
 * constants to {@code .class} files and runtime images. Developers still have meaningful labels
 * in their code (e.g. {@code DIMENSION_MISMATCH}) through a set of constants defined in interfaces.
 * This approach furthermore gives the benefit of compile-time safety. Because integer constants are
 * inlined right into class files at compile time, the declarative interface is never loaded at run
 * time. This class also provides facilities for string formatting using {@link MessageFormat}.
 *
 * @since 2.4
 * @source $URL: http://svn.osgeo.org/geotools/tags/2.7-M3/modules/library/metadata/src/main/java/org/geotools/resources/IndexedResourceBundle.java $
 * @version $Id: IndexedResourceBundle.java 34640 2009-12-10 00:29:32Z simonegiannecchini $
 * @author Martin Desruisseaux (IRD)
 */
public class IndexedResourceBundle extends ResourceBundle {
    /**
     * Maximum string length for text inserted into another text. This parameter is used by
     * {@link #summarize}. Resource strings are never cut to this length. However, text replacing
     * "{0}" in a string like "Parameter name is {0}." will be cut to this length.
     */
    private static final int MAX_STRING_LENGTH = 200;

    /**
     * The resource name of the binary file containing resources.
     * It may be a file name or an entry in a JAR file.
     */
    private final String filename;

    /**
     * The array of resources. Keys are an array index. For example, the value for key "14" is
     * {@code values[14]}. This array will be loaded only when first needed. We should not load
     * it at construction time, because some {@code ResourceBundle} objects will never ask for
     * values. This is particularly the case for ancestor classes of {@code Resources_fr_CA},
     * {@code Resources_en}, {@code Resources_de}, etc., which will only be used if a key has
     * not been found in the subclass.
     */
    private String[] values;

    /**
     * The locale for formatting objects like number, date, etc. There are two possible Locales
     * we could use: default locale or resource bundle locale. If the default locale uses the same
     * language as this ResourceBundle's locale, then we will use the default locale. This allows
     * dates and numbers to be formatted according to user conventions (e.g. French Canada) even
     * if the ResourceBundle locale is different (e.g. standard French). However, if languages
     * don't match, then we will use ResourceBundle locale for better coherence.
     */
    private transient Locale locale;

    /**
     * The object to use for formatting messages. This object
     * will be constructed only when first needed.
     */
    private transient MessageFormat format;

    /**
     * The key of the last resource requested. If the same resource is requested multiple times,
     * knowing its key allows us to avoid invoking the costly {@link MessageFormat#applyPattern}
     * method.
     */
    private transient int lastKey;

    /**
     * Constructs a new resource bundle. The resource filename will be inferred from
     * the fully qualified classname of this {@code IndexedResourceBundle} subclass.
     */
    protected IndexedResourceBundle() {
        filename = getClass().getSimpleName() + ".utf";
    }

    /**
     * Constructs a new resource bundle.
     *
     * @param filename The resource name containing resources.
     *        It may be a filename or an entry in a JAR file.
     */
    protected IndexedResourceBundle(final String filename) {
        this.filename = filename;
    }

    /**
     * Returns the locale to use for formatters.
     */
    private Locale getFormatLocale() {
        if (locale == null) {
            locale = Locale.getDefault();
            final Locale resourceLocale = getLocale();
            if (!locale.getLanguage().equalsIgnoreCase(resourceLocale.getLanguage())) {
                locale = resourceLocale;
            }
        }
        return locale;
    }

    /**
     * Returns the name of the package.
     */
    private String getPackageName() {
        final String name = getClass().getName();
        final int index = name.lastIndexOf('.');
        return (index>=0) ? name.substring(0, index) : "org.geotools";
    }

    /**
     * Lists resources to the specified stream. If a resource has more than one line, only
     * the first line will be written. This method is used mostly for debugging purposes.
     *
     * @param  out The destination stream.
     * @throws IOException if an output operation failed.
     */
    public final void list(final Writer out) throws IOException {
        // Synchronization performed by 'ensureLoaded'
        list(out, ensureLoaded(null));
    }

    /**
     * Lists resources to the specified stream. If a resource has more than one line, only
     * the first line will be written. This method is used mostly for debugging purposes.
     *
     * @param  out    The destination stream.
     * @param  values The resources to list.
     * @throws IOException if an output operation failed.
     */
    private static void list(final Writer out, final String[] values) throws IOException {
        final String lineSeparator = System.getProperty("line.separator", "\n");
        for (int i=0; i<values.length; i++) {
            String value = values[i];
            if (value == null) {
                continue;
            }
            int indexCR=value.indexOf('\r'); if (indexCR<0) indexCR=value.length();
            int indexLF=value.indexOf('\n'); if (indexLF<0) indexLF=value.length();
            final String number = String.valueOf(i);
            out.write(Utilities.spaces(5-number.length()));
            out.write(number);
            out.write(":\t");
            out.write(value.substring(0, Math.min(indexCR,indexLF)));
            out.write(lineSeparator);
        }
    }

    /**
     * Ensures that resource values are loaded. If they are not, load them immediately.
     *
     * @param  key Key for the requested resource, or {@code null} if all resources
     *         are requested. This key is used mostly for constructing messages.
     * @return The resources.
     * @throws MissingResourceException if this method failed to load resources.
     */
    private String[] ensureLoaded(final String key) throws MissingResourceException {
        LogRecord record = null;
        try {
            String[] values;
            synchronized (this) {
                values = this.values;
                if (values != null) {
                    return values;
                }
                /*
                 * Prepares a log record.  We will wait for successful loading before
                 * posting this record.  If loading fails, the record will be changed
                 * into an error record. Note that the message must be logged outside
                 * the synchronized block, otherwise there is dead locks!
                 */
                record= new LogRecord(Level.FINER, "Loaded resources for {0} from bundle \"{1}\".");
                record.setSourceClassName (getClass().getName());
                record.setSourceMethodName((key != null) ? "getObject" : "getKeys");
                /*
                 * Loads resources from the UTF file.
                 */
                InputStream in;
                String name = filename;
                while ((in = getClass().getResourceAsStream(name)) == null) {
                    final int ext  = name.lastIndexOf('.');
                    final int lang = name.lastIndexOf('_', ext-1);
                    if (lang <= 0) {
                        throw new FileNotFoundException(filename);
                    }
                    name = name.substring(0, lang) + name.substring(ext);
                }
                final DataInputStream input = new DataInputStream(new BufferedInputStream(in));
                this.values = values = new String[input.readInt()];
                for (int i=0; i<values.length; i++) {
                    values[i] = input.readUTF();
                    if (values[i].length() == 0)
                        values[i] = null;
                }
                input.close();
                /*
                 * Now, log the message. This message is not localized.
                 */
                String language = getLocale().getDisplayName(Locale.US);
                if (language==null || language.length()==0) {
                    language="<default>";
                }
                record.setParameters(new String[]{language, getPackageName()});
            }
            final Logger logger = Logging.getLogger(IndexedResourceBundle.class);
            record.setLoggerName(logger.getName());
            logger.log(record);
            return values;
        } catch (IOException exception) {
            record.setLevel  (Level.WARNING);
            record.setMessage(exception.getLocalizedMessage());
            record.setThrown (exception);
            final Logger logger = Logging.getLogger(IndexedResourceBundle.class);
            record.setLoggerName(logger.getName());
            logger.log(record);
            final MissingResourceException error = new MissingResourceException(
                    exception.getLocalizedMessage(), getClass().getName(), key);
            error.initCause(exception);
            throw error;
        }
    }

    /**
     * Returns an enumeration of the keys.
     */
    public final Enumeration<String> getKeys() {
        // Synchronization performed by 'ensureLoaded'
        final String[] values = ensureLoaded(null);
        return new Enumeration<String>() {
            private int i=0;

            public boolean hasMoreElements() {
                while (true) {
                    if (i >= values.length) return false;
                    if (values[i] != null)  return true;
                    i++;
                }
            }

            public String nextElement() {
                while (true) {
                    if (i >= values.length) throw new NoSuchElementException();
                    if (values[i] != null)  return String.valueOf(i++);
                    i++;
                }
            }
        };
    }

    /**
     * Gets an object for the given key from this resource bundle.
     * Returns null if this resource bundle does not contain an
     * object for the given key.
     *
     * @param key the key for the desired object
     * @exception NullPointerException if {@code key} is {@code null}
     * @return the object for the given key, or null
     */
    protected final Object handleGetObject(final String key) {
        // Synchronization performed by 'ensureLoaded'
        final String[] values = ensureLoaded(key);
        final int keyID;
        try {
            keyID = Integer.parseInt(key);
        } catch (NumberFormatException exception) {
            return null;
        }
        return (keyID>=0 && keyID<values.length) ? values[keyID] : null;
    }

    /**
     * Makes sure that the {@code text} string is not longer than {@code maxLength} characters.
     * If {@code text} is not longer, it is returned unchanged (except for trailing blanks,
     * which are removed). If {@code text} is longer, it will be cut somewhere in the middle.
     * This method tries to cut between two words and replace the missing words with "(...)".
     * For example, the following string:
     *
     * <blockquote>
     *   "This sentence given as an example is way too long to be
     *    included in a message."
     * </blockquote>
     *
     * May be "summarized" by something like this:
     *
     * <blockquote>
     *   "This sentence given (...) included in a message."
     * </blockquote>
     *
     * @param  text The sentence to summarize if it is too long.
     * @param  maxLength The maximum length allowed for {@code text}.
     *         If {@code text} is longer, it will be summarized.
     * @return A sentence not longer than {@code maxLength}.
     */
    private static String summarize(String text, int maxLength) {
        text=text.trim();
        final int length=text.length();
        if (length<=maxLength) {
            return text;
        }
        /*
         * Computes maximum length for one half of the string. Take into
         * account the space needed for inserting the " (...) " string.
         */
        maxLength = (maxLength-7) >> 1;
        if (maxLength<=0) {
            return text;
        }
        /*
         * We will remove characters from 'break1' to 'break2', both exclusive.
         * We try to adjust 'break1' and 'break2' in such a way that the first
         * and last characters to be removed will be spaces or punctuation
         * characters.
         * Constants 'lower' and 'upper' are limit values. If we don't find
         * values for 'break1' and 'break2' inside those limits, we will give
         * up.
         */
        int break1 = maxLength;
        int break2 = length-maxLength;
        for (final int lower=(maxLength>>1); break1>=lower; break1--) {
            if (!Character.isUnicodeIdentifierPart(text.charAt(break1))) {
                while (--break1>=lower && !Character.isUnicodeIdentifierPart(text.charAt(break1)));
                break;
            }
        }
        for (final int upper=length-(maxLength>>1); break2<upper; break2++) {
            if (!Character.isUnicodeIdentifierPart(text.charAt(break2))) {
                while (++break2<upper && !Character.isUnicodeIdentifierPart(text.charAt(break2)));
                break;
            }
        }
        return (text.substring(0,break1+1)+" (...) "+text.substring(break2)).trim();
    }

    /**
     * Returns {@code arguments} as an array. If {@code arguments} is already an array, this array
     * or a copy of this array will be returned. If {@code arguments} is not an array, it will be
     * placed in an array of length 1. In any case, all the array's elements will be checked for
     * {@link String} objects. Any strings of length greater than {@link #MAX_STRING_LENGTH} will
     * be reduced using the {@link #summarize} method.
     *
     * @param  arguments The object to check.
     * @return {@code arguments} as an array.
     */
    private Object[] toArray(final Object arguments) {
        Object[] array;
        if (arguments instanceof Object[]) {
            array = (Object[]) arguments;
        } else {
            array = new Object[] {arguments};
        }
        for (int i=0; i<array.length; i++) {
            final Object element = array[i];
            if (element instanceof CharSequence) {
                final String s0;
                if (element instanceof InternationalString) {
                    s0 = ((InternationalString) element).toString(getFormatLocale());
                } else {
                    s0 = element.toString();
                }
                final String s1 = summarize(s0, MAX_STRING_LENGTH);
                if (s0!=s1 && !s0.equals(s1)) {
                    if (array == arguments) {
                        array = new Object[array.length];
                        System.arraycopy(arguments, 0, array, 0, array.length);
                    }
                    array[i] = s1;
                }
            } else if (element instanceof Throwable) {
                String message = ((Throwable) element).getLocalizedMessage();
                if (message == null) {
                    message = Classes.getShortClassName(element);
                }
                array[i] = message;
            } else if (element instanceof Class) {
                array[i] = Classes.getShortName((Class<?>) element);
            }
        }
        return array;
    }

    /**
     * Gets a string for the given key and appends "..." to it.
     * This method is typically used for creating menu items.
     *
     * @param  key The key for the desired string.
     * @return The string for the given key.
     * @throws MissingResourceException If no object for the given key can be found.
     */
    public final String getMenuLabel(final int key) throws MissingResourceException {
        return getString(key) + "...";
    }

    /**
     * Gets a string for the given key and appends ": " to it.
     * This method is typically used for creating labels.
     *
     * @param  key The key for the desired string.
     * @return The string for the given key.
     * @throws MissingResourceException If no object for the given key can be found.
     */
    public final String getLabel(final int key) throws MissingResourceException {
        return getString(key) + ": ";
    }

    /**
     * Gets a string for the given key from this resource bundle or one of its parents.
     *
     * @param  key The key for the desired string.
     * @return The string for the given key.
     * @throws MissingResourceException If no object for the given key can be found.
     */
    public final String getString(final int key) throws MissingResourceException {
        return getString(String.valueOf(key));
    }

    /**
     * Gets a string for the given key and formats it with the specified argument. The message is
     * formatted using {@link MessageFormat}. Calling this method is approximately equivalent to
     * calling:
     *
     * <blockquote><pre>
     *   String pattern = getString(key);
     *   Format f = new MessageFormat(pattern);
     *   return f.format(arg0);
     * </pre></blockquote>
     *
     * If {@code arg0} is not already an array, it will be placed into an array of length 1. Using
     * {@link MessageFormat}, all occurrences of "{0}", "{1}", "{2}" in the resource string will be
     * replaced by {@code arg0[0]}, {@code arg0[1]}, {@code arg0[2]}, etc.
     *
     * @param  key The key for the desired string.
     * @param  arg0 A single object or an array of objects to be formatted and substituted.
     * @return The string for the given key.
     * @throws MissingResourceException If no object for the given key can be found.
     *
     * @see #getString(String)
     * @see #getString(int,Object,Object)
     * @see #getString(int,Object,Object,Object)
     * @see MessageFormat
     */
    public final String getString(final int key, final Object arg0) throws MissingResourceException {
        final String pattern = getString(key);
        final Object[] arguments = toArray(arg0);
        synchronized (this) {
            if (format == null) {
                /*
                 * Constructs a new MessageFormat for formatting the arguments.
                 */
                format = new MessageFormat(pattern, getFormatLocale());
            } else if (key != lastKey) {
                /*
                 * Method MessageFormat.applyPattern(...) is costly! We will avoid
                 * calling it again if the format already has the right pattern.
                 */
                format.applyPattern(pattern);
                lastKey = key;
            }
            return format.format(arguments);
        }
    }

    /**
     * Gets a string for the given key and replaces all occurrences of "{0}",
     * "{1}", with values of {@code arg0}, {@code arg1}, etc.
     *
     * @param  key The key for the desired string.
     * @param  arg0 Value to substitute for "{0}".
     * @param  arg1 Value to substitute for "{1}".
     * @return The formatted string for the given key.
     * @throws MissingResourceException If no object for the given key can be found.
     */
    public final String getString(final int    key,
                                  final Object arg0,
                                  final Object arg1) throws MissingResourceException
    {
        return getString(key, new Object[] {arg0, arg1});
    }

    /**
     * Gets a string for the given key and replaces all occurrences of "{0}",
     * "{1}", with values of {@code arg0}, {@code arg1}, etc.
     *
     * @param  key The key for the desired string.
     * @param  arg0 Value to substitute for "{0}".
     * @param  arg1 Value to substitute for "{1}".
     * @param  arg2 Value to substitute for "{2}".
     * @return The formatted string for the given key.
     * @throws MissingResourceException If no object for the given key can be found.
     */
    public final String getString(final int    key,
                                  final Object arg0,
                                  final Object arg1,
                                  final Object arg2) throws MissingResourceException
    {
        return getString(key, new Object[] {arg0, arg1, arg2});
    }

    /**
     * Gets a string for the given key and replaces all occurrences of "{0}",
     * "{1}", with values of {@code arg0}, {@code arg1}, etc.
     *
     * @param  key The key for the desired string.
     * @param  arg0 Value to substitute for "{0}".
     * @param  arg1 Value to substitute for "{1}".
     * @param  arg2 Value to substitute for "{2}".
     * @param  arg3 Value to substitute for "{3}".
     * @return The formatted string for the given key.
     * @throws MissingResourceException If no object for the given key can be found.
     */
    public final String getString(final int    key,
                                  final Object arg0,
                                  final Object arg1,
                                  final Object arg2,
                                  final Object arg3) throws MissingResourceException
    {
        return getString(key, new Object[] {arg0, arg1, arg2, arg3});
    }

    /**
     * Gets a string for the given key and replaces all occurrences of "{0}",
     * "{1}", with values of {@code arg0}, {@code arg1}, etc.
     *
     * @param  key The key for the desired string.
     * @param  arg0 Value to substitute for "{0}".
     * @param  arg1 Value to substitute for "{1}".
     * @param  arg2 Value to substitute for "{2}".
     * @param  arg3 Value to substitute for "{3}".
     * @param  arg4 Value to substitute for "{4}".
     * @return The formatted string for the given key.
     * @throws MissingResourceException If no object for the given key can be found.
     */
    public final String getString(final int    key,
                                  final Object arg0,
                                  final Object arg1,
                                  final Object arg2,
                                  final Object arg3,
                                  final Object arg4) throws MissingResourceException
    {
        return getString(key, new Object[] {arg0, arg1, arg2, arg3, arg4});
    }

    /**
     * Gets a localized log record.
     *
     * @param  level The log record level.
     * @param  key   The resource key.
     * @return The log record.
     */
    public LogRecord getLogRecord(final Level level, final int key) {
        return getLogRecord(level, key, null);
    }

    /**
     * Gets a localized log record.
     *
     * @param  level The log record level.
     * @param  key   The resource key.
     * @param  arg0  The parameter for the log message, or {@code null}.
     * @return The log record.
     */
    public LogRecord getLogRecord(final Level level, final int key,
                                  final Object arg0)
    {
        final LogRecord record = new LogRecord(level, String.valueOf(key));
        record.setResourceBundle(this);
        if (arg0 != null) {
            record.setParameters(toArray(arg0));
        }
        return record;
    }

    /**
     * Gets a localized log record.
     *
     * @param  level The log record level.
     * @param  key   The resource key.
     * @param  arg0  The first parameter.
     * @param  arg1  The second parameter.
     * @return The log record.
     */
    public LogRecord getLogRecord(final Level level, final int key,
                                  final Object arg0,
                                  final Object arg1)
    {
        return getLogRecord(level, key, new Object[]{arg0, arg1});
    }

    /**
     * Gets a localized log record.
     *
     * @param  level The log record level.
     * @param  key   The resource key.
     * @param  arg0  The first parameter.
     * @param  arg1  The second parameter.
     * @param  arg2  The third parameter.
     * @return The log record.
     */
    public LogRecord getLogRecord(final Level level, final int key,
                                  final Object arg0,
                                  final Object arg1,
                                  final Object arg2)
    {
        return getLogRecord(level, key, new Object[]{arg0, arg1, arg2});
    }

    /**
     * Gets a localized log record.
     *
     * @param  level The log record level.
     * @param  key   The resource key.
     * @param  arg0  The first parameter.
     * @param  arg1  The second parameter.
     * @param  arg2  The third parameter.
     * @param  arg3  The fourth parameter.
     * @return The log record.
     */
    public LogRecord getLogRecord(final Level level, final int key,
                                  final Object arg0,
                                  final Object arg1,
                                  final Object arg2,
                                  final Object arg3)
    {
        return getLogRecord(level, key, new Object[]{arg0, arg1, arg2, arg3});
    }

    /**
     * Localize and format the message string from a log record. This method performs a work
     * similar to {@link java.util.logging.Formatter#formatMessage}, except that the work will be
     * delegated to {@link #getString(int, Object)} if the {@linkplain LogRecord#getResourceBundle
     * record resource bundle} is an instance of {@code IndexedResourceBundle}.
     *
     * @param  record The log record to format.
     * @return The formatted message.
     */
    public static String format(final LogRecord record) {
        String message = record.getMessage();
        final ResourceBundle resources = record.getResourceBundle();
        if (resources instanceof IndexedResourceBundle) {
            int key = -1;
            try {
                key = Integer.parseInt(message);
            } catch (NumberFormatException e) {
                 unexpectedException(e);
            }
            if (key >= 0) {
                final Object[] parameters = record.getParameters();
                return ((IndexedResourceBundle) resources).getString(key, parameters);
            }
        }
        if (resources != null) {
            try {
                message = resources.getString(message);
            } catch (MissingResourceException e) {
                unexpectedException(e);
            }
            final Object[] parameters = record.getParameters();
            if (parameters != null && parameters.length != 0) {
                final int offset = message.indexOf('{');
                if (offset>=0 && offset<message.length()-1) {
                    // Uses a more restrictive check than Character.isDigit(char)
                    final char c = message.charAt(offset);
                    if (c>='0' && c<='9') try {
                        return MessageFormat.format(message, parameters);
                    } catch (IllegalArgumentException e) {
                        unexpectedException(e);
                    }
                }
            }
        }
        return message;
    }

    /**
     * Invoked when an unexpected exception occured in the {@link #format} method.
     */
    private static void unexpectedException(final RuntimeException exception) {
        Logging.unexpectedException(IndexedResourceBundle.class, "format", exception);
    }

    /**
     * Returns a string representation of this object.
     * This method is for debugging purposes only.
     */
    @Override
    public synchronized String toString() {
        final StringBuilder buffer = new StringBuilder(Classes.getShortClassName(this));
        buffer.append('[');
        if (values != null) {
            int count = 0;
            for (int i=0; i<values.length; i++) {
                if (values[i]!=null) count++;
            }
            buffer.append(count);
            buffer.append(" values");
        }
        buffer.append(']');
        return buffer.toString();
    }
}