/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.sis.util.internal;

import java.text.Format;
import java.util.Map;
import java.util.Arrays;
import java.util.Currency;
import java.util.Locale;
import java.util.TimeZone;
import java.io.IOException;
import java.nio.charset.Charset;
import org.opengis.util.Type;
import org.opengis.util.Record;
import org.opengis.util.GenericName;
import org.opengis.util.InternationalString;
import org.apache.sis.io.CompoundFormat;
import org.apache.sis.io.LineAppender;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.Workaround;
import org.apache.sis.util.Localized;
import org.apache.sis.util.resources.Vocabulary;

// Specific to the main branch:
import org.opengis.util.CodeList;


/**
 * Creates string representation of property values of unknown type.
 * Tabulations are replaced by spaces, and line feeds can optionally
 * be replaced by the Pilcrow character.
 *
 * Subclasses need to override {@link #getLocale()}, and should also override {@link #toString(Object)}.
 *
 * @author  Martin Desruisseaux (Geomatys)
 */
public abstract class PropertyFormat extends LineAppender implements Localized {
    /**
     * The string to insert for missing values.
     */
    private static final String MISSING = " ";

    /**
     * The format for the column in process of being written. This is a format to use for the column as a whole.
     * This field is updated for every new column to write. May be {@code null} if the format is unspecified.
     */
    protected transient Format columnFormat;

    /**
     * Creates a new instance which will write to the given appendable.
     *
     * @param  out  where to format the objects.
     */
    protected PropertyFormat(final Appendable out) {
        super(out);
    }

    /**
     * Appends a textual representation of the given value.
     *
     * @param  value  the value to format (may be {@code null}).
     * @throws IOException if an error occurred while writing the value.
     */
    public final void appendValue(final Object value) throws IOException {
        appendValue(value, false);
    }

    /**
     * Appends a textual representation of the given value, with a check for nested collections.
     *
     * @param  value      the value to format (may be {@code null}).
     * @param  recursive  {@code true} if this method is invoking itself for writing collection values.
     */
    private void appendValue(final Object value, final boolean recursive) throws IOException {
        final CharSequence text;
        if (value == null) {
            text = MISSING;
        } else if (columnFormat != null) {
            if (columnFormat instanceof CompoundFormat<?>) {
                appendCompound((CompoundFormat<?>) columnFormat, value);
                return;
            }
            text = columnFormat.format(value);
        } else if (value instanceof InternationalString) {
            text = freeText(((InternationalString) value).toString(getLocale()));
        } else if (value instanceof CharSequence) {
            text = freeText(value.toString());
        } else if (value instanceof CodeList<?>) {
            text = MetadataServices.getInstance().getCodeTitle((CodeList<?>) value, getLocale());
        } else if (value instanceof Enum<?>) {
            text = CharSequences.upperCaseToSentence(((Enum<?>) value).name());
        } else if (value instanceof Boolean) {
            text = Vocabulary.getResources(getLocale()).getString((Boolean) value ? Vocabulary.Keys.True : Vocabulary.Keys.False);
        } else if (value instanceof Type) {
            appendName(((Type) value).getTypeName());
            return;
        } else if (value instanceof Locale) {
            final Locale locale = getLocale();
            text = (locale != Locale.ROOT) ? ((Locale) value).getDisplayName(locale) : value.toString();
        } else if (value instanceof TimeZone) {
            final Locale locale = getLocale();
            text = (locale != Locale.ROOT) ? ((TimeZone) value).getDisplayName(locale) : ((TimeZone) value).getID();
        } else if (value instanceof Charset) {
            final Locale locale = getLocale();
            text = (locale != Locale.ROOT) ? ((Charset) value).displayName(locale) : ((Charset) value).name();
        } else if (value instanceof Currency) {
            final Locale locale = getLocale();
            text = (locale != Locale.ROOT) ? ((Currency) value).getDisplayName(locale) : value.toString();
        } else if (value instanceof Record) {
            appendCollection(((Record) value).getAttributes().values(), recursive);
            return;
        } else if (value instanceof Iterable<?>) {
            appendCollection((Iterable<?>) value, recursive);
            return;
        } else if (value instanceof Object[]) {
            appendCollection(Arrays.asList((Object[]) value), recursive);
            return;
        } else if (value instanceof Map.Entry<?,?>) {
            final Map.Entry<?,?> entry = (Map.Entry<?,?>) value;
            final Object k = entry.getKey();
            final Object v = entry.getValue();
            if (k == null) {
                append(null);
            } else {
                appendValue(k, recursive);
            }
            if (v != null) {
                append(" → ");
                appendValue(v, recursive);
            }
            return;
        } else {
            text = toString(value);
        }
        append(text);
    }

    /**
     * Invoked by {@link PropertyFormat} for formatting a value which has not been recognized as one of the types
     * to be handled in a special way. Some of the types handled in a special way are {@link InternationalString},
     * {@link CodeList}, {@link Enum}, {@link Type}, {@link Locale}, {@link TimeZone}, {@link Charset},
     * {@link Currency}, {@link Record}, {@link Iterable} and arrays. Other types should be handled by this method.
     * In particular, {@link Number}, {@link java.util.Date} and {@link org.apache.sis.measure.Angle}
     * are <strong>not</strong> handled by default by this {@link PropertyFormat} class and should be handled here.
     *
     * @param  value  the value to format (never {@code null}).
     * @return the formatted value.
     */
    protected String toString(final Object value) {
        return freeText(value.toString());
    }

    /**
     * Invoked after formatting a text that could be anything. It current version, it includes all kinds of
     * {@link CharSequence} including {@link InternationalString}, together with {@link Object#toString()}
     * values computed by the default {@link #toString(Object)} implementation.
     *
     * The default {@code freeText(…)} implementation removes white space and control characters.
     * Subclasses can override for example for making a text shorter.
     *
     * @param  text  the free text, or {@code null}.
     * @return the text to append.
     */
    protected String freeText(final String text) {
        // Really want `trim()` because there is sometimes control characters to remove.
        return (text != null) ? text.trim() : MISSING;
    }

    /**
     * Writes the values of the given collection. A maximum of 10 values will be written.
     * If the collection contains other collections, the other collections will <strong>not</strong>
     * be written recursively.
     */
    private void appendCollection(final Iterable<?> values, final boolean recursive) throws IOException {
        if (values != null) {
            if (recursive) {
                append('…');                                // Do not format collections inside collections.
            } else {
                int count = 0;
                for (final Object value : values) {
                    if (value != null) {
                        if (count != 0) append(", ");
                        appendValue(value, true);
                        if (++count == 10) {                // Arbitrary limit.
                            append(", …");
                            break;
                        }
                    }
                }
            }
        }
    }

    /**
     * Workaround for the inability to define the variable {@code <V>} locally.
     */
    @Workaround(library="JDK", version="1.7")
    private <V> void appendCompound(final CompoundFormat<V> format, final Object value) throws IOException {
        format.format(format.getValueType().cast(value), this);
    }

    /**
     * Localizes the given name in the display locale, or formats "(Unnamed)" if no localized value is found.
     */
    private void appendName(final GenericName name) throws IOException {
        final Locale locale = getLocale();
        if (name != null) {
            final InternationalString i18n = name.toInternationalString();
            if (i18n != null) {
                final String localized = i18n.toString(locale);
                if (localized != null) {
                    append(localized);
                    return;
                }
            }
            final String localized = name.toString();
            if (localized != null) {
                append(localized);
                return;
            }
        }
        append('(').append(Vocabulary.getResources(locale).getString(Vocabulary.Keys.Unnamed)).append(')');
    }
}
