/*
 * 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.feature;

import java.util.List;
import java.util.ArrayList;
import java.util.Set;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.Collection;
import java.util.Locale;
import java.util.TimeZone;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.text.Format;
import java.text.FieldPosition;
import java.text.ParsePosition;
import java.text.ParseException;
import java.util.concurrent.atomic.AtomicReference;
import org.opengis.referencing.IdentifiedObject;
import org.opengis.util.InternationalString;
import org.opengis.util.GenericName;
import org.apache.sis.io.TableAppender;
import org.apache.sis.io.TabularFormat;
import org.apache.sis.util.Deprecable;
import org.apache.sis.util.Characters;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.resources.Vocabulary;
import org.apache.sis.util.internal.Strings;
import org.apache.sis.util.internal.CollectionsExt;
import org.apache.sis.geometry.wrapper.Geometries;
import org.apache.sis.geometry.wrapper.GeometryWrapper;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.math.MathFunctions;


/**
 * Formats {@linkplain AbstractFeature features} or {@linkplain DefaultFeatureType feature types} in a tabular format.
 * This format assumes a monospaced font and an encoding supporting drawing box characters (e.g. UTF-8).
 *
 * <h2>Example</h2>
 * A feature named “City” and containing 3 properties (“name”, “population” and “twin town”)
 * may be formatted like below. The two first properties are {@linkplain AbstractAttribute attributes}
 * while the last property is an {@linkplain AbstractAssociation association} to another feature.
 *
 * <pre class="text">
 *   City
 *   ┌────────────┬─────────┬──────────────┬───────────┐
 *   │ Name       │ Type    │ Multiplicity │ Value     │
 *   ├────────────┼─────────┼──────────────┼───────────┤
 *   │ name       │ String  │ [1 … 1]      │ Paderborn │
 *   │ population │ Integer │ [1 … 1]      │ 143,174   │
 *   │ twin town  │ City    │ [0 … ∞]      │ Le Mans   │
 *   └────────────┴─────────┴──────────────┴───────────┘</pre>
 *
 * <h2>Limitations</h2>
 * <ul>
 *   <li>The current implementation can only format features — parsing is not yet implemented.</li>
 *   <li>{@code FeatureFormat}, like most {@code java.text.Format} subclasses, is not thread-safe.</li>
 * </ul>
 *
 * @author  Martin Desruisseaux (Geomatys)
 * @version 1.4
 * @since   0.5
 */
public class FeatureFormat extends TabularFormat<Object> {
    /**
     * For cross-version compatibility.
     */
    private static final long serialVersionUID = -5792086817264884947L;

    /**
     * The separator to use in comma-separated lists.
     */
    private static final String SEPARATOR = ", ";

    /**
     * An instance created when first needed and potentially shared.
     */
    private static final AtomicReference<FeatureFormat> INSTANCE = new AtomicReference<>();

    /**
     * The locale for international strings.
     */
    private final Locale displayLocale;

    /**
     * The columns to include in the table formatted by this {@code FeatureFormat}.
     * By default, all columns having at least one value are included.
     */
    private final EnumSet<Column> columns = EnumSet.allOf(Column.class);

    /**
     * Maximal length of attribute values, in number of characters.
     * If a value is longer than this length, it will be truncated.
     *
     * <p>This is defined as a static final variable for now because its value is approximate:
     * it is a number of characters instead of a number of code points, and that length may be
     * exceeded by a few characters if the overflow happen while appending the list separator.</p>
     */
    private static final int MAXIMAL_VALUE_LENGTH = 40;

    /**
     * The bit patterns of the last {@link Float#NaN} value for which {@link MathFunctions#toNanOrdinal(float)} could
     * not get the ordinal value. We use this information for avoiding flooding the logger with the same message.
     */
    private transient int illegalNaN;

    /**
     * Creates a new formatter for the default locale and timezone.
     */
    public FeatureFormat() {
        super(Locale.getDefault(Locale.Category.FORMAT), TimeZone.getDefault());
        displayLocale = Locale.getDefault(Locale.Category.DISPLAY);
        columnSeparator = " │ ";
    }

    /**
     * Creates a new formatter for the given locale and timezone.
     *
     * @param  locale    the locale, or {@code null} for {@code Locale.ROOT}.
     * @param  timezone  the timezone, or {@code null} for UTC.
     */
    public FeatureFormat(final Locale locale, final TimeZone timezone) {
        super(locale, timezone);
        displayLocale = (locale != null) ? locale : Locale.ROOT;
        columnSeparator = " │ ";
    }

    /**
     * Returns the type of objects formatted by this class. This method has to return {@code Object.class}
     * since it is the only common parent to {@code Feature} and {@link FeatureType}.
     *
     * @return {@code Object.class}
     */
    @Override
    public final Class<Object> getValueType() {
        return Object.class;
    }

    /**
     * Returns the locale for the given category.
     *
     * <ul>
     *   <li>{@link java.util.Locale.Category#FORMAT} specifies the locale to use for values.</li>
     *   <li>{@link java.util.Locale.Category#DISPLAY} specifies the locale to use for labels.</li>
     * </ul>
     *
     * @param  category  the category for which a locale is desired.
     * @return the locale for the given category (never {@code null}).
     */
    @Override
    public Locale getLocale(final Locale.Category category) {
        return (category == Locale.Category.DISPLAY) ? displayLocale : super.getLocale(category);
    }

    /**
     * Returns all columns that may be shown in the tables to format.
     * The columns included in the set may be shown, but not necessarily;
     * some columns will still be omitted if they are completely empty.
     * However, columns <em>not</em> included in the set are guaranteed to be omitted.
     *
     * @return all columns that may be shown in the tables to format.
     *
     * @since 0.8
     */
    public Set<Column> getAllowedColumns() {
        return columns.clone();
    }

    /**
     * Sets all columns that may be shown in the tables to format.
     * Note that the columns specified to this method are not guaranteed to be shown;
     * some columns will still be omitted if they are completely empty.
     *
     * @param inclusion  all columns that may be shown in the tables to format.
     *
     * @since 0.8
     */
    public void setAllowedColumns(final Set<Column> inclusion) {
        ArgumentChecks.ensureNonNull("inclusion", inclusion);
        columns.clear();
        columns.addAll(inclusion);
    }

    /**
     * Identifies the columns to include in the table formatted by {@code FeatureFormat}.
     * By default, all columns having at least one non-null value are shown. But a smaller
     * set of columns can be specified to the {@link FeatureFormat#setAllowedColumns(Set)}
     * method for formatting narrower tables.
     *
     * @see FeatureFormat#setAllowedColumns(Set)
     *
     * @since 0.8
     */
    public enum Column {
        /**
         * Natural language designator for the property.
         * This is the character sequence returned by {@link AbstractIdentifiedType#getDesignation()}.
         * This column is omitted if no property has a designation.
         */
        DESIGNATION(Vocabulary.Keys.Designation),

        /**
         * Name of the property.
         * This is the character sequence returned by {@link AbstractIdentifiedType#getName()}.
         */
        NAME(Vocabulary.Keys.Name),

        /**
         * Type of property values. This is the type returned by {@link DefaultAttributeType#getValueClass()} or
         * {@link DefaultAssociationRole#getValueType()}.
         */
        TYPE(Vocabulary.Keys.Type),

        /**
         * Cardinality (for attributes) or multiplicity (for attribute types).
         * The cardinality is the actual number of attribute values.
         * The multiplicity is the minimum and maximum occurrences of attribute values.
         * The multiplicity is made from the numbers returned by {@link DefaultAttributeType#getMinimumOccurs()}
         * and {@link DefaultAttributeType#getMaximumOccurs()}.
         */
        CARDINALITY(Vocabulary.Keys.Cardinality),

        /**
         * Property value (for properties) or default value (for property types).
         * This is the value returned by {@link AbstractAttribute#getValue()}, {@link AbstractAssociation#getValue()}
         * or {@link DefaultAttributeType#getDefaultValue()}.
         */
        VALUE(Vocabulary.Keys.Value),

        /**
         * Other attributes that describes the attribute.
         * This is made from the map returned by {@link AbstractAttribute#characteristics()}.
         * This column is omitted if no property has characteristics.
         */
        CHARACTERISTICS(Vocabulary.Keys.Characteristics),

        /**
         * Whether a property is deprecated, or other remarks.
         * This column is omitted if no property has remarks.
         */
        REMARKS(Vocabulary.Keys.Remarks);

        /**
         * The {@link Vocabulary} key to use for formatting the header of this column.
         */
        final short resourceKey;

        /**
         * Creates a new column enumeration constant.
         */
        private Column(final short key) {
            resourceKey = key;
        }
    }

    /**
     * Invoked when the formatter needs to move to the next column.
     */
    private void nextColumn(final TableAppender table) {
        table.append(beforeFill);
        table.nextColumn(fillCharacter);
    }

    /**
     * Formats the given object to the given stream of buffer.
     * The object may be an instance of any of the following types:
     *
     * <ul>
     *   <li>{@code Feature}</li>
     *   <li>{@code FeatureType}</li>
     * </ul>
     *
     * @throws IOException if an error occurred while writing to the given appendable.
     */
    @Override
    public void format(final Object object, final Appendable toAppendTo) throws IOException {
        ArgumentChecks.ensureNonNull("object",     object);
        ArgumentChecks.ensureNonNull("toAppendTo", toAppendTo);
        /*
         * Separate the Feature (optional) and the FeatureType (mandatory) instances.
         */
        final DefaultFeatureType featureType;
        final AbstractFeature feature;
        if (object instanceof AbstractFeature) {
            feature     = (AbstractFeature) object;
            featureType = feature.getType();
        } else if (object instanceof DefaultFeatureType) {
            featureType = (DefaultFeatureType) object;
            feature     = null;
        } else {
            throw new IllegalArgumentException(Errors.getResources(displayLocale)
                    .getString(Errors.Keys.UnsupportedType_1, object.getClass()));
        }
        /*
         * Computes the columns to show. We start with the set of columns specified by setAllowedColumns(Set),
         * then we check if some of those columns are empty. For example, in many cases there are no attributes
         * with characteritic, in which case we will ommit the whole "characteristics" column. We perform such
         * check only for optional information, not for mandatory information like property names.
         */
        final EnumSet<Column> visibleColumns = columns.clone();
        {
            boolean hasDesignation     = false;
            boolean hasCharacteristics = false;
            boolean hasDeprecatedTypes = false;
            for (final AbstractIdentifiedType propertyType : featureType.getProperties(true)) {
                if (!hasDesignation) {
                    hasDesignation = propertyType.getDesignation() != null;
                }
                if (!hasCharacteristics && propertyType instanceof DefaultAttributeType<?>) {
                    hasCharacteristics = !((DefaultAttributeType<?>) propertyType).characteristics().isEmpty();
                }
                if (!hasDeprecatedTypes && propertyType instanceof Deprecable) {
                    hasDeprecatedTypes = ((Deprecable) propertyType).isDeprecated();
                }
            }
            if (!hasDesignation)     visibleColumns.remove(Column.DESIGNATION);
            if (!hasCharacteristics) visibleColumns.remove(Column.CHARACTERISTICS);
            if (!hasDeprecatedTypes) visibleColumns.remove(Column.REMARKS);
        }
        /*
         * Format the feature type name. In the case of feature type, format also the names of super-type
         * after the UML symbol for inheritance (an arrow with white head). We do not use the " : " ASCII
         * character for avoiding confusion with the ":" separator in namespaces. After the feature (type)
         * name, format the column header: property name, type, cardinality and (default) value.
         */
        toAppendTo.append(toString(featureType.getName()));
        if (feature == null) {
            String separator = " ⇾ ";                                       // UML symbol for inheritance.
            for (final FeatureType parent : featureType.getSuperTypes()) {
                toAppendTo.append(separator).append(toString(parent.getName()));
                separator = SEPARATOR;
            }
            final InternationalString definition = featureType.getDefinition();
            if (definition != null) {
                final String text = Strings.trimOrNull(definition.toString(displayLocale));
                if (text != null) {
                    toAppendTo.append(getLineSeparator()).append(text);
                }
            }
        }
        toAppendTo.append(getLineSeparator());
        /*
         * Create a table and format the header. Columns will be shown in Column enumeration order.
         */
        final Vocabulary resources = Vocabulary.getResources(displayLocale);
        final TableAppender table = new TableAppender(toAppendTo, columnSeparator);
        table.setMultiLinesCells(true);
        table.nextLine('─');
        boolean isFirstColumn = true;
        for (final Column column : visibleColumns) {
            short key = column.resourceKey;
            if (feature == null) {
                if (key == Vocabulary.Keys.Cardinality) key = Vocabulary.Keys.Multiplicity;
                if (key == Vocabulary.Keys.Value)       key = Vocabulary.Keys.DefaultValue;
            }
            if (!isFirstColumn) nextColumn(table);
            table.append(resources.getString(key));
            isFirstColumn = false;
        }
        table.nextLine();
        table.nextLine('─');
        /*
         * Done writing the header. Now write all property rows.  For each row, the first part in the loop
         * extracts all information needed without formatting anything yet. If we detect in that part that
         * a row has no value, it will be skipped if and only if that row is optional (minimum occurrence
         * of zero).
         */
        final StringBuffer  buffer  = new StringBuffer();
        final FieldPosition dummyFP = new FieldPosition(-1);
        final List<String>  remarks = new ArrayList<>();
        for (final AbstractIdentifiedType propertyType : featureType.getProperties(true)) {
            Object value = null;
            int cardinality = -1;
            if (feature != null) {
                if (!(propertyType instanceof DefaultAttributeType<?>) &&
                    !(propertyType instanceof DefaultAssociationRole) &&
                    !DefaultFeatureType.isParameterlessOperation(propertyType))
                {
                    continue;
                }
                value = feature.getPropertyValue(propertyType.getName().toString());
                if (value == null) {
                    if (propertyType instanceof DefaultAttributeType<?>
                            && ((DefaultAttributeType<?>) propertyType).getMinimumOccurs() == 0
                            && ((DefaultAttributeType<?>) propertyType).characteristics().isEmpty())
                    {
                        continue;                           // If optional, no value and no characteristics, skip the full row.
                    }
                    if (propertyType instanceof DefaultAssociationRole
                            && ((DefaultAssociationRole) propertyType).getMinimumOccurs() == 0)
                    {
                        continue;                           // If optional and no value, skip the full row.
                    }
                    cardinality = 0;
                } else if (value instanceof Collection<?>) {
                    cardinality = ((Collection<?>) value).size();
                } else {
                    cardinality = 1;
                }
            } else if (propertyType instanceof DefaultAttributeType<?>) {
                value = ((DefaultAttributeType<?>) propertyType).getDefaultValue();
            } else if (propertyType instanceof AbstractOperation) {
                buffer.append(" = ");
                try {
                    ((AbstractOperation) propertyType).formatResultFormula(buffer);
                } catch (IOException e) {
                    throw new UncheckedIOException(e);      // Should never happen since we write in a StringBuffer.
                }
                value = CharSequences.trimWhitespaces(buffer).toString();
                buffer.setLength(0);
            }
            final String   valueType;                       // The value to write in the type column.
            final Class<?> valueClass;                      // AttributeType.getValueClass() if applicable.
            final int minimumOccurs, maximumOccurs;         // Negative values mean no cardinality.
            final AbstractIdentifiedType resultType;        // Result of operation if applicable.
            if (propertyType instanceof AbstractOperation) {
                resultType = ((AbstractOperation) propertyType).getResult();        // May be null
            } else {
                resultType = propertyType;
            }
            if (resultType instanceof DefaultAttributeType<?>) {
                final DefaultAttributeType<?> pt = (DefaultAttributeType<?>) resultType;
                minimumOccurs = pt.getMinimumOccurs();
                maximumOccurs = pt.getMaximumOccurs();
                valueClass    = pt.getValueClass();
                valueType     = getFormat(Class.class).format(valueClass, buffer, dummyFP).toString();
                buffer.setLength(0);
            } else if (resultType instanceof DefaultAssociationRole) {
                final DefaultAssociationRole pt = (DefaultAssociationRole) resultType;
                minimumOccurs = pt.getMinimumOccurs();
                maximumOccurs = pt.getMaximumOccurs();
                valueType     = toString(DefaultAssociationRole.getValueTypeName(pt));
                valueClass    = AbstractFeature.class;
            } else {
                valueType  = (resultType != null) ? toString(resultType.getName()) : "";
                valueClass = null;
                minimumOccurs = -1;
                maximumOccurs = -1;
            }
            /*
             * At this point we determined that the row should not be skipped
             * and we got all information to format.
             */
            isFirstColumn = true;
            for (final Column column : visibleColumns) {
                if (!isFirstColumn) nextColumn(table);
                isFirstColumn = false;
                switch (column) {
                    /*
                     * Human-readable name of the property. May contains any characters (spaces, ideographs, etc).
                     * In many cases, this information is not provided and the whole column is skipped.
                     */
                    case DESIGNATION: {
                        final InternationalString d = propertyType.getDesignation();
                        if (d != null) table.append(d.toString(displayLocale));
                        break;
                    }
                    /*
                     * Machine-readable name of the property (identifier). This information is mandatory.
                     * This name is usually shorter than the designation and should contain only valid
                     * Unicode identifier characters (e.g. no spaces).
                     */
                    case NAME: {
                        table.append(toString(propertyType.getName()));
                        break;
                    }
                    /*
                     * The base class or interface for all values in properties of the same type.
                     * This is typically String, Number, Integer, Geometry or URL.
                     */
                    case TYPE: {
                        table.append(valueType);
                        break;
                    }
                    /*
                     * Minimum and maximum number of occurrences allowed for this property.
                     * If we are formatting a Feature instead of a FeatureType, then the
                     * actual number of values is also formatted. Example: 42 ∈ [0 … ∞]
                     */
                    case CARDINALITY: {
                        table.setCellAlignment(TableAppender.ALIGN_RIGHT);
                        if (cardinality >= 0) {
                            table.append(getFormat(Integer.class).format(cardinality, buffer, dummyFP));
                            buffer.setLength(0);
                        }
                        if (maximumOccurs >= 0) {
                            if (cardinality >= 0) {
                                table.append(' ')
                                     .append((cardinality >= minimumOccurs && cardinality <= maximumOccurs) ? '∈' : '∉')
                                     .append(' ');
                            }
                            final Format format = getFormat(Integer.class);
                            table.append('[').append(format.format(minimumOccurs, buffer, dummyFP)).append(" … ");
                            buffer.setLength(0);
                            if (maximumOccurs != Integer.MAX_VALUE) {
                                table.append(format.format(maximumOccurs, buffer, dummyFP));
                            } else {
                                table.append('∞');
                            }
                            buffer.setLength(0);
                            table.append(']');
                        }
                        break;
                    }
                    /*
                     * If formatting a FeatureType, the default value. If formatting a Feature, the actual value.
                     * A java.text.Format instance dedicated to the value class is used if possible. In addition
                     * to types for which a java.text.Format may be available, we also have to check for other
                     * special cases. If there is more than one value, they are formatted as a coma-separated list.
                     */
                    case VALUE: {
                        table.setCellAlignment(TableAppender.ALIGN_LEFT);
                        final Format format = getFormat(valueClass);                            // Null if valueClass is null.
                        final Iterator<?> it = CollectionsExt.toCollection(value).iterator();
                        String separator = "";
                        int length = 0;
                        while (it.hasNext()) {
                            value = it.next();
                            if (value != null) {
                                if (propertyType instanceof DefaultAssociationRole) {
                                    final String p = DefaultAssociationRole.getTitleProperty((DefaultAssociationRole) propertyType);
                                    if (p != null) {
                                        value = ((AbstractFeature) value).getPropertyValue(p);
                                        if (value == null) continue;
                                    }
                                } else if (format != null && valueClass.isInstance(value)) {    // Null safe because of getFormat(valueClass) contract.
                                    /*
                                     * Convert numbers, dates, angles, etc. to character sequences before to append them in the table.
                                     * Note that DecimalFormat writes Not-a-Number as "NaN" in some locales and as "�" in other locales
                                     * (U+FFFD - Unicode replacement character). The "�" seems to be used mostly for historical reasons;
                                     * as of 2017 the Unicode Common Locale Data Repository (CLDR) seems to define "NaN" for all locales.
                                     * We could configure DecimalFormatSymbols for using "NaN", but (for now) we rather substitute "�" by
                                     * "NaN" here for avoiding to change the DecimalFormat configuration and for distinguishing the NaNs.
                                     */
                                    final StringBuffer t = format.format(value, buffer, dummyFP);
                                    if (value instanceof Number) {
                                        final float f = ((Number) value).floatValue();
                                        if (Float.isNaN(f)) {
                                            if ("�".contentEquals(t)) {
                                                t.setLength(0);
                                                t.append("NaN");
                                            }
                                            try {
                                                final int n = MathFunctions.toNanOrdinal(f);
                                                if (n > 0) t.append(" #").append(n);
                                            } catch (IllegalArgumentException e) {
                                                // May happen if the NaN is a signaling NaN instead of a quiet NaN.
                                                final int bits = Float.floatToRawIntBits(f);
                                                if (bits != illegalNaN) {
                                                    illegalNaN = bits;
                                                    Logging.recoverableException(AbstractIdentifiedType.LOGGER, FeatureFormat.class, "format", e);
                                                }
                                            }
                                        }
                                    }
                                    value = t;
                                }
                                /*
                                 * All values: the numbers, dates, angles, etc. formatted above, any other character sequences
                                 * (e.g. InternationalString), or other kind of values - some of them handled in a special way.
                                 */
                                length = formatValue(value, table.append(separator), length);
                                buffer.setLength(0);
                                if (length < 0) break;      // Value is too long, abandon remaining iterations.
                                separator = SEPARATOR;
                                length += SEPARATOR.length();
                            }
                        }
                        break;
                    }
                    /*
                     * Characteristics are optional information attached to some values. For example if a property
                     * value is a temperature measurement, a characteritic of that value may be the unit of measure.
                     * Characteristics are handled as "attributes of attributes".
                     */
                    case CHARACTERISTICS: {
                        if (propertyType instanceof DefaultAttributeType<?>) {
                            int length = 0;
                            String separator = "";
format:                     for (final DefaultAttributeType<?> ct : ((DefaultAttributeType<?>) propertyType).characteristics().values()) {
                                /*
                                 * Format the characteristic name. We will append the value(s) later.
                                 * We keep trace of the text length in order to stop formatting if the
                                 * text become too long.
                                 */
                                final GenericName cn = ct.getName();
                                final String cs = toString(cn);
                                table.append(separator).append(cs);
                                length += separator.length() + cs.length();
                                Collection<?> cv = CollectionsExt.singletonOrEmpty(ct.getDefaultValue());
                                if (feature != null) {
                                    /*
                                     * Usually, the property `cp` below is null because all features use the same
                                     * characteristic value (for example the same unit of measurement),  which is
                                     * given by the default value `cv`.  Nevertheless we have to check if current
                                     * feature overrides this characteristic.
                                     */
                                    final Object cp = feature.getProperty(propertyType.getName().toString());
                                    if (cp instanceof AbstractAttribute<?>) {            // Should always be true, but we are paranoiac.
                                        AbstractAttribute<?> ca = ((AbstractAttribute<?>) cp).characteristics().get(cn.toString());
                                        if (ca != null) cv = ca.getValues();
                                    }
                                }
                                /*
                                 * Now format the value, separated from the name with " = ". Example: unit = m/s
                                 * If the value accepts multi-occurrences, we will format the value between {…}.
                                 * We use {…} because we may have more than one characteristic in the same cell,
                                 * so we need a way to distinguish multi-values from multi-characteristics.
                                 */
                                final boolean multi = ct.getMaximumOccurs() > 1;
                                String sep = multi ? " = {" : " = ";
                                for (Object c : cv) {
                                    length = formatValue(c, table.append(sep), length += sep.length());
                                    if (length < 0) break format;   // Value is too long, abandon remaining iterations.
                                    sep = SEPARATOR;
                                }
                                separator = SEPARATOR;
                                if (multi && sep == SEPARATOR) {
                                    table.append('}');
                                }
                            }
                        }
                        break;
                    }
                    case REMARKS: {
                        if (org.apache.sis.feature.Field.isDeprecated(propertyType)) {
                            table.append(resources.getString(Vocabulary.Keys.Deprecated));
                            final InternationalString r = ((Deprecable) propertyType).getRemarks();
                            if (r != null) {
                                remarks.add(r.toString(displayLocale));
                                appendSuperscript(remarks.size(), table);
                            }
                        }
                        break;
                    }
                }
            }
            table.nextLine();
        }
        table.nextLine('─');
        table.flush();
        /*
         * If there is any remarks, write them below the table.
         */
        final int n = remarks.size();
        for (int i=0; i<n; i++) {
            appendSuperscript(i+1, toAppendTo);
            toAppendTo.append(' ').append(remarks.get(i)).append(lineSeparator);
        }
    }

    /**
     * Returns the display name for the given {@code GenericName}.
     */
    private String toString(final GenericName name) {
        if (name == null) {                                             // Should not be null, but let be safe.
            return "";
        }
        final InternationalString i18n = name.toInternationalString();
        if (i18n != null) {                                             // Should not be null, but let be safe.
            final String s = i18n.toString(displayLocale);
            if (s != null) {
                return s;
            }
        }
        return name.toString();
    }

    /**
     * Appends the given attribute value, in a truncated form if it exceed the maximal value length.
     *
     * @param  value   the value to append.
     * @param  table   where to append the value.
     * @param  length  number of characters appended before this method call in the current table cell.
     * @return number of characters appended after this method call in the current table cell, or -1 if
     *         the length exceed the maximal length (in which case the caller should break iteration).
     */
    private int formatValue(final Object value, final TableAppender table, final int length) {
        String text;
        if (value instanceof InternationalString) {
            text = ((InternationalString) value).toString(displayLocale);
        } else if (value instanceof GenericName) {
            text = toString((GenericName) value);
        } else if (value instanceof AbstractIdentifiedType) {
            text = toString(((AbstractIdentifiedType) value).getName());
        } else if (value instanceof IdentifiedObject) {
            text = IdentifiedObjects.getIdentifierOrName((IdentifiedObject) value);
        } else {
            text = Geometries.wrap(value).map(GeometryWrapper::toString).orElseGet(value::toString);
        }
        final int remaining = MAXIMAL_VALUE_LENGTH - length;
        if (remaining >= text.length()) {
            table.append(text);
            return length + text.length();
        } else {
            table.append(text, 0, Math.max(0, remaining - 1)).append('…');
            return -1;
        }
    }

    /**
     * Appends the given number as an superscript if possible, or as an ordinary number otherwise.
     */
    private static void appendSuperscript(final int n, final Appendable toAppendTo) throws IOException {
        if (n >= 0 && n < 10) {
            toAppendTo.append(Characters.toSuperScript((char) ('0' + n)));
        } else {
            toAppendTo.append('(').append(String.valueOf(n)).append(')');
        }
    }

    /**
     * Formats the given object using a shared instance of {@code ParameterFormat}.
     * This is used for {@link DefaultFeatureType#toString()} implementation.
     */
    static String sharedFormat(final Object object) {
        FeatureFormat f = INSTANCE.getAndSet(null);
        if (f == null) {
            f = new FeatureFormat();
        }
        final String s = f.format(object);
        INSTANCE.set(f);
        return s;
    }

    /**
     * Not yet supported.
     *
     * @return currently never return.
     * @throws ParseException currently always thrown.
     */
    @Override
    public Object parse(final CharSequence text, final ParsePosition pos) throws ParseException {
        throw new ParseException(Errors.getResources(displayLocale)
                .getString(Errors.Keys.UnsupportedOperation_1, "parse"), pos.getIndex());
    }

    /**
     * Returns a clone of this format.
     *
     * @return a clone of this format.
     */
    @Override
    public FeatureFormat clone() {
        return (FeatureFormat) super.clone();
    }
}
