/*
 *    GeoTools - The Open Source Java GIS Toolkit
 *    http://geotools.org
 *
 *    (C) 2004-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.util;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import org.opengis.util.InternationalString;
import org.geotools.util.logging.Logging;
import org.geotools.resources.i18n.Errors;
import org.geotools.resources.i18n.ErrorKeys;


/**
 * An implementation of international string using a {@linkplain Map map}
 * of strings for different {@linkplain Locale locales}. Strings for new
 * locales can be {@linkplain #add(Locale,String) added}, but existing
 * strings can't be removed or modified. This behavior is a compromise
 * between making constructionss easier, and being suitable for use in
 * immutable objects.
 *
 * @since 2.1
 *
 * @source $URL: http://svn.osgeo.org/geotools/branches/2.7.x/modules/library/metadata/src/main/java/org/geotools/util/GrowableInternationalString.java $
 * @version $Id: GrowableInternationalString.java 37298 2011-05-25 05:16:15Z mbedward $
 * @author Martin Desruisseaux (IRD)
 */
public class GrowableInternationalString extends AbstractInternationalString implements Serializable {
    /**
     * Serial number for interoperability with different versions.
     */
    private static final long serialVersionUID = 5760033376627376937L;

    /**
     * The set of locales created in this virtual machine through methods of this class.
     * Used in order to get a {@linkplain #unique unique} instance of {@link Locale} objects.
     */
    private static final Map<Locale,Locale> LOCALES = new HashMap<Locale,Locale>();

    /**
     * The string values in different locales (never {@code null}).
     * Keys are {@link Locale} objects and values are {@link String}s.
     */
    private Map<Locale,String> localMap;

    /**
     * An unmodifiable view of the entry set in {@link #localMap}. This is the set of locales
     * defined in this international string. Will be constructed only when first requested.
     */
    private transient Set<Locale> localSet;

    /**
     * Constructs an initially empty international string. Localized strings can been added
     * using one of {@link #add add(...)} methods.
     */
    public GrowableInternationalString() {
        localMap = Collections.emptyMap();
    }

    /**
     * Adds a string for the given locale.
     *
     * @param  locale The locale for the {@code string} value, or {@code null}.
     * @param  string The localized string.
     * @throws IllegalArgumentException if a different string value was already set for
     *         the given locale.
     */
    public synchronized void add(final Locale locale, final String string)
            throws IllegalArgumentException
    {
        if (string != null) {
            switch (localMap.size()) {
                case 0: {
                    localMap = Collections.singletonMap(locale, string);
                    defaultValue = null; // Will be recomputed when first needed.
                    return;
                }
                case 1: {
                    localMap = new HashMap<Locale,String>(localMap);
                    break;
                }
            }
            String old = localMap.get(locale);
            if (old != null) {
                if (string.equals(old)) {
                    return;
                }
                // TODO: provide a localized message "String value already set for locale ...".
                throw new IllegalArgumentException();
            }
            localMap.put(locale, string);
            defaultValue = null; // Will be recomputed when first needed.
        }
    }

    /**
     * Adds a string for the given property key. This is a convenience method for constructing an
     * {@code AbstractInternationalString} during iteration through the
     * {@linkplain java.util.Map.Entry entries} in a {@link Map}. It infers the {@link Locale}
     * from the property {@code key}, using the following steps:
     * <ul>
     *   <li>If the {@code key} do not starts with the specified {@code prefix}, then
     *       this method do nothing and returns {@code false}.</li>
     *   <li>Otherwise, the characters after the {@code prefix} are parsed as an ISO language
     *       and country code, and the {@link #add(Locale,String)} method is
     *       invoked.</li>
     * </ul>
     *
     * <P>For example if the prefix is <code>"remarks"</code>, then the <code>"remarks_fr"</code>
     * property key stands for remarks in {@linkplain Locale#FRENCH French} while the
     * <code>"remarks_fr_CA"</code> property key stands for remarks in
     * {@linkplain Locale#CANADA_FRENCH French Canadian}.</P>
     *
     * @param  prefix The prefix to skip at the begining of the {@code key}.
     * @param  key The property key.
     * @param  string The localized string for the specified {@code key}.
     * @return {@code true} if the key has been recognized, or {@code false} otherwise.
     * @throws IllegalArgumentException if the locale after the prefix is an illegal code, or a
     *         different string value was already set for the given locale.
     */
    public boolean add(final String prefix, final String key, final String string)
            throws IllegalArgumentException
    {
        if (!key.startsWith(prefix)) {
            return false;
        }
        int position  = prefix.length();
        final int length = key.length();
        final String[] parts = new String[] {"", "", ""};
        for (int i=0; /*break condition inside*/; i++) {
            if (position == length) {
                final Locale locale = (i==0) ? (Locale)null :
                       unique(new Locale(parts[0] /* language */,
                                         parts[1] /* country  */,
                                         parts[2] /* variant  */));
                add(locale, string);
                return true;
            }
            if (key.charAt(position)!='_' || i==parts.length) {
                // Unknow character, or two many characters
                break;
            }
            int next = key.indexOf('_', ++position);
            if (next < 0) {
                next = length;
            } else if (next == position) {
                // Found two consecutive '_' characters
                break;
            }
            parts[i] = key.substring(position, position=next);
        }
        throw new IllegalArgumentException(Errors.format(ErrorKeys.ILLEGAL_ARGUMENT_$2,
                                           "locale", key.substring(prefix.length())));
    }

    /**
     * Returns a canonical instance of the given locale.
     *
     * @param  locale The locale to canonicalize.
     * @return The canonical instance of {@code locale}.
     */
    private static synchronized Locale unique(final Locale locale) {
        /**
         * Initialize the LOCALES map with the set of locales defined in the Locale class.
         * This operation is done only once.
         */
        if (LOCALES.isEmpty()) try {
            final Field[] fields = Locale.class.getFields();
            for (int i=0; i<fields.length; i++) {
                final Field field = fields[i];
                if (Modifier.isStatic(field.getModifiers())) {
                    if (Locale.class.isAssignableFrom(field.getType())) {
                        final Locale toAdd = (Locale) field.get(null);
                        LOCALES.put(toAdd, toAdd);
                    }
                }
            }
        } catch (Exception exception) {
            /*
             * Not a big deal if this operation fails (this is actually just an
             * optimization for reducing memory usage). Log a warning and continue.
             */
            Logging.unexpectedException(GrowableInternationalString.class, "unique", exception);
        }
        /*
         * Now canonicalize the locale.
         */
        final Locale candidate = LOCALES.get(locale);
        if (candidate != null) {
            return candidate;
        }
        LOCALES.put(locale, locale);
        return locale;
    }

    /**
     * Returns the set of locales defined in this international string.
     *
     * @return The set of locales.
     */
    public synchronized Set<Locale> getLocales() {
        if (localSet == null) {
            localSet = Collections.unmodifiableSet(localMap.keySet());
        }
        return localSet;
    }

    /**
     * Returns a string in the specified locale. If there is no string for the specified
     * {@code locale}, then this method search for a locale without the
     * {@linkplain Locale#getVariant variant} part. If no string are found,
     * then this method search for a locale without the {@linkplain Locale#getCountry country}
     * part. For example if the <code>"fr_CA"</code> locale was requested but not found, then
     * this method looks for the <code>"fr"</code> locale. The {@code null} locale
     * (which stand for unlocalized message) is tried last.
     *
     * @param  locale The locale to look for, or {@code null}.
     * @return The string in the specified locale, or in a default locale.
     */
    public synchronized String toString(Locale locale) {
        String text;
        while (locale != null) {
            text = localMap.get(locale);
            if (text != null) {
                return text;
            }
            final String language = locale.getLanguage();
            final String country  = locale.getCountry ();
            final String variant  = locale.getVariant ();
            if (variant.length() != 0) {
                locale = new Locale(language, country);
                continue;
            }
            if (country.length() != 0) {
                locale = new Locale(language);
                continue;
            }
            break;
        }

        // Tries the string in the 'null' locale.
        text = localMap.get(null);
        if (text == null) {
            // No 'null' locale neither. Returns the first string in whatever locale.
            final Iterator<String> it = localMap.values().iterator();
            if (it.hasNext()) {
                return it.next();
            }
        }
        return text;
    }

    /**
     * Returns {@code true} if all localized texts stored in this international string are
     * contained in the specified object. More specifically:
     *
     * <ul>
     *   <li><p>If {@code candidate} is an instance of {@link InternationalString}, then this method
     *       returns {@code true} if, for all <var>{@linkplain Locale locale}</var>-<var>{@linkplain
     *       String string}</var> pairs contained in {@code this}, <code>candidate.{@linkplain
     *       InternationalString#toString(Locale) toString}(locale)</code> returns a string
     *       {@linkplain String#equals equals} to {@code string}.</p></li>
     *
     *   <li><p>If {@code candidate} is an instance of {@link CharSequence}, then this method
     *       returns {@code true} if {@link #toString(Locale)} returns a string {@linkplain
     *       String#equals equals} to <code>candidate.{@linkplain CharSequence#toString()
     *       toString()}</code> for all locales.</p></li>
     *
     *   <li><p>If {@code candidate} is an instance of {@link Map}, then this methods returns
     *       {@code true} if all <var>{@linkplain Locale locale}</var>-<var>{@linkplain String
     *       string}</var> pairs are contained into {@code candidate}.</p></li>
     *
     *   <li><p>Otherwise, this method returns {@code false}.</p></li>
     * </ul>
     *
     * @param  candidate The object which may contains this international string.
     * @return {@code true} if the given object contains all localized strings found in this
     *         international string.
     *
     * @since 2.3
     */
    public boolean isSubsetOf(final Object candidate) {
        if (candidate instanceof InternationalString) {
            final InternationalString string = (InternationalString) candidate;
            for (final Map.Entry<Locale,String> entry : localMap.entrySet()) {
                final Locale locale = entry.getKey();
                final String text   = entry.getValue();
                if (!text.equals(string.toString(locale))) {
                    return false;
                }
            }
        } else if (candidate instanceof CharSequence) {
            final String string = candidate.toString();
            for (final String text : localMap.values()) {
                if (!text.equals(string)) {
                    return false;
                }
            }
        } else if (candidate instanceof Map) {
            final Map<?,?> map = (Map<?,?>) candidate;
            return map.entrySet().containsAll(localMap.entrySet());
        } else {
            return false;
        }
        return true;
    }

    /**
     * Compares this international string with the specified object for equality.
     *
     * @param object The object to compare with this international string.
     * @return {@code true} if the given object is equals to this string.
     */
    @Override
    public boolean equals(final Object object) {
        if (object!=null && object.getClass().equals(getClass())) {
            final GrowableInternationalString that = (GrowableInternationalString) object;
            return Utilities.equals(this.localMap, that.localMap);
        }
        return false;
    }

    /**
     * Returns a hash code value for this international text.
     */
    @Override
    public int hashCode() {
        return (int)serialVersionUID ^ localMap.hashCode();
    }

    /**
     * Canonicalize the locales after deserialization.
     */
    private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        final int size = localMap.size();
        if (size == 0) {
            return;
        }
        @SuppressWarnings("unchecked")
        Map.Entry<Locale,String>[] entries = new Map.Entry[size];
        entries = localMap.entrySet().toArray(entries);
        if (size == 1) {
            final Map.Entry<Locale,String> entry = entries[0];
            localMap = Collections.singletonMap(unique(entry.getKey()), entry.getValue());
        } else {
            localMap.clear();
            for (int i=0; i<entries.length; i++) {
                final Map.Entry<Locale,String> entry = entries[i];
                localMap.put(unique(entry.getKey()), entry.getValue());
            }
        }
    }
}
