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

import java.time.LocalDate;
import java.util.Date;
import java.util.Locale;
import java.util.Optional;
import java.util.Iterator;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.net.URI;
import java.nio.charset.Charset;
import javax.measure.Unit;
import javax.measure.quantity.Length;
import org.opengis.util.MemberName;
import org.opengis.util.GenericName;
import org.opengis.util.InternationalString;
import org.opengis.geometry.Envelope;
import org.opengis.geometry.Geometry;
import org.opengis.metadata.Metadata;
import org.opengis.metadata.Identifier;
import org.opengis.metadata.citation.Role;
import org.opengis.metadata.citation.DateType;
import org.opengis.metadata.citation.Citation;
import org.opengis.metadata.citation.CitationDate;
import org.opengis.metadata.citation.OnLineFunction;
import org.opengis.metadata.identification.Identification;
import org.opengis.metadata.spatial.GCP;
import org.opengis.metadata.spatial.Dimension;
import org.opengis.metadata.spatial.DimensionNameType;
import org.opengis.metadata.spatial.CellGeometry;
import org.opengis.metadata.spatial.PixelOrientation;
import org.opengis.metadata.spatial.GeolocationInformation;
import org.opengis.metadata.spatial.SpatialRepresentationType;
import org.opengis.metadata.constraint.Restriction;
import org.opengis.metadata.content.CoverageContentType;
import org.opengis.metadata.content.TransferFunctionType;
import org.opengis.metadata.maintenance.ScopeCode;
import org.opengis.metadata.acquisition.Context;
import org.opengis.metadata.acquisition.OperationType;
import org.opengis.metadata.identification.Progress;
import org.opengis.metadata.identification.KeywordType;
import org.opengis.metadata.identification.TopicCategory;
import org.opengis.metadata.distribution.Format;
import org.opengis.metadata.quality.Element;
import org.opengis.geometry.DirectPosition;
import org.opengis.referencing.ReferenceSystem;
import org.opengis.referencing.cs.CoordinateSystem;
import org.opengis.referencing.crs.VerticalCRS;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.operation.TransformException;
import org.apache.sis.internal.util.CollectionsExt;
import org.apache.sis.geometry.AbstractEnvelope;
import org.apache.sis.metadata.iso.DefaultMetadata;
import org.apache.sis.metadata.iso.DefaultIdentifier;
import org.apache.sis.metadata.iso.DefaultMetadataScope;
import org.apache.sis.metadata.iso.extent.DefaultExtent;
import org.apache.sis.metadata.iso.extent.DefaultVerticalExtent;
import org.apache.sis.metadata.iso.extent.DefaultTemporalExtent;
import org.apache.sis.metadata.iso.extent.DefaultBoundingPolygon;
import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox;
import org.apache.sis.metadata.iso.extent.DefaultGeographicDescription;
import org.apache.sis.metadata.iso.spatial.DefaultGridSpatialRepresentation;
import org.apache.sis.metadata.iso.spatial.DefaultDimension;
import org.apache.sis.metadata.iso.spatial.DefaultGeorectified;
import org.apache.sis.metadata.iso.spatial.DefaultGeoreferenceable;
import org.apache.sis.metadata.iso.spatial.DefaultGCPCollection;
import org.apache.sis.metadata.iso.spatial.DefaultGCP;
import org.apache.sis.metadata.iso.content.DefaultAttributeGroup;
import org.apache.sis.metadata.iso.content.DefaultSampleDimension;
import org.apache.sis.metadata.iso.content.DefaultCoverageDescription;
import org.apache.sis.metadata.iso.content.DefaultFeatureCatalogueDescription;
import org.apache.sis.metadata.iso.content.DefaultRangeElementDescription;
import org.apache.sis.metadata.iso.content.DefaultImageDescription;
import org.apache.sis.metadata.iso.content.DefaultFeatureTypeInfo;
import org.apache.sis.metadata.iso.citation.Citations;
import org.apache.sis.metadata.iso.citation.AbstractParty;
import org.apache.sis.metadata.iso.citation.DefaultCitation;
import org.apache.sis.metadata.iso.citation.DefaultCitationDate;
import org.apache.sis.metadata.iso.citation.DefaultResponsibility;
import org.apache.sis.metadata.iso.citation.DefaultIndividual;
import org.apache.sis.metadata.iso.citation.DefaultOrganisation;
import org.apache.sis.metadata.iso.citation.DefaultOnlineResource;
import org.apache.sis.metadata.iso.constraint.DefaultLegalConstraints;
import org.apache.sis.metadata.iso.identification.AbstractIdentification;
import org.apache.sis.metadata.iso.identification.DefaultKeywords;
import org.apache.sis.metadata.iso.identification.DefaultResolution;
import org.apache.sis.metadata.iso.identification.DefaultDataIdentification;
import org.apache.sis.metadata.iso.distribution.DefaultFormat;
import org.apache.sis.metadata.iso.distribution.DefaultDistributor;
import org.apache.sis.metadata.iso.distribution.DefaultDistribution;
import org.apache.sis.metadata.iso.acquisition.DefaultAcquisitionInformation;
import org.apache.sis.metadata.iso.acquisition.DefaultEvent;
import org.apache.sis.metadata.iso.acquisition.DefaultInstrument;
import org.apache.sis.metadata.iso.acquisition.DefaultOperation;
import org.apache.sis.metadata.iso.acquisition.DefaultPlatform;
import org.apache.sis.metadata.iso.acquisition.DefaultRequirement;
import org.apache.sis.metadata.iso.lineage.DefaultLineage;
import org.apache.sis.metadata.iso.lineage.DefaultProcessStep;
import org.apache.sis.metadata.iso.lineage.DefaultProcessing;
import org.apache.sis.metadata.iso.lineage.DefaultSource;
import org.apache.sis.metadata.iso.maintenance.DefaultScope;
import org.apache.sis.metadata.iso.maintenance.DefaultScopeDescription;
import org.apache.sis.metadata.sql.MetadataStoreException;
import org.apache.sis.metadata.sql.MetadataSource;
import org.apache.sis.coverage.grid.GridGeometry;
import org.apache.sis.coverage.grid.GridExtent;
import org.apache.sis.coverage.SampleDimension;
import org.apache.sis.internal.util.Numerics;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.iso.Names;
import org.apache.sis.util.iso.Types;
import org.apache.sis.measure.Units;

import static org.apache.sis.internal.util.StandardDateFormat.MILLISECONDS_PER_DAY;

// Branch-dependent imports
import org.opengis.metadata.citation.ResponsibleParty;
import org.opengis.metadata.identification.CharacterSet;
import org.apache.sis.feature.DefaultFeatureType;
import org.apache.sis.metadata.iso.citation.DefaultResponsibleParty;


/**
 * Helper methods for the metadata created by {@code DataStore} implementations.
 * This is not a general-purpose builder suitable for public API, since the
 * methods provided in this class are tailored for Apache SIS data store needs.
 * API of this class may change in any future SIS versions.
 *
 * @author  Martin Desruisseaux (Geomatys)
 * @author  Rémi Maréchal (Geomatys)
 * @author  Thi Phuong Hao Nguyen (VNSC)
 * @version 1.0
 * @since   0.8
 * @module
 */
public class MetadataBuilder {
    /**
     * Whether the next party to create should be an instance of {@link DefaultIndividual} or {@link DefaultOrganisation}.
     *
     * @see #party()
     * @see #newParty(PartyType)
     */
    private PartyType partyType = PartyType.UNKNOWN;

    /**
     * Whether the next grid should be an instance of {@link DefaultGeorectified} or {@link DefaultGeoreferenceable}.
     *
     * @see #gridRepresentation()
     * @see #newGridRepresentation(GridType)
     */
    private GridType gridType = GridType.UNSPECIFIED;

    /**
     * {@code true} if the next {@code CoverageDescription} to create will be a description of measurements
     * in the electromagnetic spectrum. In that case, the coverage description will actually be an instance
     * of {@code ImageDescription}.
     *
     * @see #coverageDescription()
     * @see #newCoverage(boolean)
     */
    private boolean electromagnetic;

    /**
     * For using the same instance of {@code Integer} or {@code Double} when the value is the same.
     * Also used for reusing {@link Citation} instances already created for a given title.
     * Keys and values can be:
     *
     * <table class="sis">
     *   <tr><th>Key</th>                          <th>Value</th>               <th>Method</th></tr>
     *   <tr><td>{@link Integer}</td>              <td>{@link Integer}</td>     <td>{@link #shared(int)}</td></tr>
     *   <tr><td>{@link Double}</td>               <td>{@link Double}</td>      <td>{@link #shared(double)}</td></tr>
     *   <tr><td>{@link Identifier}</td>           <td>{@link Identifier}</td>  <td>{@link #sharedIdentifier(CharSequence, String)}</td></tr>
     *   <tr><td>{@link InternationalString}</td>  <td>{@link Citation}</td>    <td>{@link #sharedCitation(InternationalString)}</td></tr>
     *   <tr><td>Other</td>                        <td>Same as key</td>         <td>{@link #shared(Class, Object)}</td></tr>
     * </table>
     */
    private final Map<Object,Object> sharedValues = new HashMap<>();

    /**
     * Whether to add ISO 19115-1 and ISO 19115-2 entries in "metadata standards" node.
     * Those entries will be added only if the metadata object would be otherwise non-empty.
     * A value of 1 will add ISO 19115-1. A value of 2 will add both ISO 19115-1 and ISO 19115-2.
     *
     * @see #setISOStandards(boolean)
     */
    private byte standardISO;

    // Other fields declared below together with closely related methods.

    /**
     * Creates a new metadata builder.
     */
    public MetadataBuilder() {
    }

    /**
     * The metadata created by this builder, or {@code null} if not yet created.
     */
    private DefaultMetadata metadata;

    /**
     * Creates the metadata object if it does not already exists, then returns it.
     *
     * @return the metadata (never {@code null}).
     * @see #build(boolean)
     */
    private DefaultMetadata metadata() {
        if (metadata == null) {
            metadata = new DefaultMetadata();
        }
        return metadata;
    }

    /**
     * The identification information that are part of {@linkplain #metadata}, or {@code null} if none.
     */
    private DefaultDataIdentification identification;

    /**
     * Creates the identification information object if it does not already exists, then returns it.
     *
     * @return the identification information (never {@code null}).
     * @see #newIdentification()
     */
    private DefaultDataIdentification identification() {
        if (identification == null) {
            identification = new DefaultDataIdentification();
        }
        return identification;
    }

    /**
     * The citation of data {@linkplain #identification}, or {@code null} if none.
     */
    private DefaultCitation citation;

    /**
     * Creates the citation object if it does not already exists, then returns it.
     *
     * @return the citation information (never {@code null}).
     */
    private DefaultCitation citation() {
        if (citation == null) {
            citation = new DefaultCitation();
        }
        return citation;
    }

    /**
     * Part of the responsible party of the {@linkplain #citation}, or {@code null} if none.
     */
    private DefaultResponsibleParty responsibility;

    /**
     * Creates the responsibility object if it does not already exists, then returns it.
     *
     * @return the responsibility party (never {@code null}).
     */
    private DefaultResponsibleParty responsibility() {
        if (responsibility == null) {
            responsibility = new DefaultResponsibleParty();
        }
        return responsibility;
    }

    /**
     * Part of the responsible party of the {@linkplain #citation}, or {@code null} if none.
     */
    private AbstractParty party;

    /**
     * Creates the individual or organization information object if it does not already exists, then returns it.
     *
     * <p><b>Limitation:</b> if the party type is unknown, then this method creates an {@code AbstractParty} instead
     * than one of the subtypes. This is not valid, but we currently have no way to guess if a party is an individual
     * or an organization. For now we prefer to let users know that the type is unknown rather than to pick a
     * potentially wrong type.</p>
     *
     * @return the individual or organization information (never {@code null}).
     * @see #newParty(MetadataBuilder.PartyType)
     */
    private AbstractParty party() {
        if (party == null) {
            switch (partyType) {
                case UNKNOWN:      party = new AbstractParty();       break;
                case INDIVIDUAL:   party = new DefaultIndividual();   break;
                case ORGANISATION: party = new DefaultOrganisation(); break;
                default:           throw new AssertionError(partyType);
            }
        }
        return party;
    }

    /**
     * Copyright information, or {@code null} if none.
     */
    private DefaultLegalConstraints constraints;

    /**
     * Creates the constraints information object if it does not already exists, then returns it.
     *
     * @return the constraints information (never {@code null}).
     */
    private DefaultLegalConstraints constraints() {
        if (constraints == null) {
            constraints = new DefaultLegalConstraints();
        }
        return constraints;
    }

    /**
     * The extent information that are part of {@linkplain #identification}, or {@code null} if none.
     */
    private DefaultExtent extent;

    /**
     * Creates the extent information object if it does not already exists, then returns it.
     *
     * @return the extent information (never {@code null}).
     */
    private DefaultExtent extent() {
        if (extent == null) {
            extent = new DefaultExtent();
        }
        return extent;
    }

    /**
     * Information about the platforms and sensors that collected the data, or {@code null} if none.
     */
    private DefaultAcquisitionInformation acquisition;

    /**
     * Creates the acquisition information object if it does not already exists, then returns it.
     *
     * @return the acquisition information (never {@code null}).
     * @see #newAcquisition()
     */
    private DefaultAcquisitionInformation acquisition() {
        if (acquisition == null) {
            acquisition = new DefaultAcquisitionInformation();
        }
        return acquisition;
    }

    /**
     * Platform where are installed the sensors that collected the data, or {@code null} if none.
     */
    private DefaultPlatform platform;

    /**
     * Creates a platform object if it does not already exists, then returns it.
     *
     * @return the platform information (never {@code null}).
     */
    private DefaultPlatform platform() {
        if (platform == null) {
            platform = new DefaultPlatform();
        }
        return platform;
    }

    /**
     * Information about the feature types, or {@code null} if none.
     */
    private DefaultFeatureCatalogueDescription featureDescription;

    /**
     * Creates the feature descriptions object if it does not already exists, then returns it.
     * This method sets the {@code includedWithDataset} property to {@code true} because the
     * metadata built by this helper class are typically encoded together with the data.
     *
     * @return the feature descriptions (never {@code null}).
     * @see #newFeatureTypes()
     */
    private DefaultFeatureCatalogueDescription featureDescription() {
        if (featureDescription == null) {
            featureDescription = new DefaultFeatureCatalogueDescription();
            featureDescription.setIncludedWithDataset(true);
        }
        return featureDescription;
    }

    /**
     * Information about the content of a grid data cell, or {@code null} if none.
     * May also be an instance of {@link DefaultImageDescription} if {@link #electromagnetic} is {@code true}.
     */
    private DefaultCoverageDescription coverageDescription;

    /**
     * Creates the coverage description object if it does not already exists, then returns it.
     *
     * @return the coverage description (never {@code null}).
     * @see #newCoverage(boolean)
     */
    private DefaultCoverageDescription coverageDescription() {
        if (coverageDescription == null) {
            coverageDescription = electromagnetic ? new DefaultImageDescription() : new DefaultCoverageDescription();
        }
        return coverageDescription;
    }

    /**
     * Information about content type for groups of attributes for a specific range dimension, or {@code null} if none.
     */
    private DefaultAttributeGroup attributeGroup;

    /**
     * Creates the attribute group object if it does not already exists, then returns it.
     *
     * @return the attribute group (never {@code null}).
     */
    private DefaultAttributeGroup attributeGroup() {
        if (attributeGroup == null) {
            attributeGroup = new DefaultAttributeGroup();
        }
        return attributeGroup;
    }

    /**
     * The characteristic of each dimension (layer) included in the resource, or {@code null} if none.
     */
    private DefaultSampleDimension sampleDimension;

    /**
     * Creates the sample dimension object if it does not already exists, then returns it.
     *
     * @return the sample dimension (never {@code null}).
     * @see #newSampleDimension()
     */
    private DefaultSampleDimension sampleDimension() {
        if (sampleDimension == null) {
            sampleDimension = new DefaultSampleDimension();
        }
        return sampleDimension;
    }

    /**
     * Information about the grid shape, or {@code null} if none.
     */
    private DefaultGridSpatialRepresentation gridRepresentation;

    /**
     * Creates a grid representation object if it does not already exists, then returns it.
     *
     * @return the grid representation object (never {@code null}).
     * @see #newGridRepresentation(GridType)
     */
    private DefaultGridSpatialRepresentation gridRepresentation() {
        if (gridRepresentation == null) {
            switch (gridType) {
                case GEORECTIFIED:     gridRepresentation = new DefaultGeorectified(); break;
                case GEOREFERENCEABLE: gridRepresentation = new DefaultGeoreferenceable(); break;
                default:               gridRepresentation = new DefaultGridSpatialRepresentation(); break;
            }
        }
        return gridRepresentation;
    }

    /**
     * Collection of ground control points.
     */
    private DefaultGCPCollection groundControlPoints;

    /**
     * Creates the collection of ground control points if it does not already exists, then returns it.
     *
     * @return the ground control points (never {@code null}).
     */
    private DefaultGCPCollection groundControlPoints() {
        if (groundControlPoints == null) {
            groundControlPoints = new DefaultGCPCollection();
        }
        return groundControlPoints;
    }

    /**
     * Information about the distributor of and options for obtaining the resource.
     */
    private DefaultDistribution distribution;

    /**
     * Creates the distribution information object if it does not already exists, then returns it.
     *
     * @return the distribution information (never {@code null}).
     * @see #newDistribution()
     */
    private DefaultDistribution distribution() {
        if (distribution == null) {
            distribution = new DefaultDistribution();
        }
        return distribution;
    }

    /**
     * The distribution format, or {@code null} if none.
     * This is part of the resource {@linkplain #identification}.
     */
    private Format format;

    /**
     * Creates the distribution format object if it does not already exists, then returns it.
     *
     * @return the distribution format (never {@code null}).
     */
    private DefaultFormat format() {
        DefaultFormat df = DefaultFormat.castOrCopy(format);
        if (df == null) {
            format = df = new DefaultFormat();
        }
        return df;
    }

    /**
     * Information about the events or source data used in constructing the data specified by the
     * {@linkplain DefaultLineage#getScope() scope}.
     */
    private DefaultLineage lineage;

    /**
     * Creates the lineage object if it does not already exists, then returns it.
     *
     * @return the lineage (never {@code null}).
     * @see #newLineage()
     */
    private DefaultLineage lineage() {
        if (lineage == null) {
            lineage = new DefaultLineage();
        }
        return lineage;
    }

    /**
     * Information about an event or transformation in the life of a resource.
     * This is part of {@link #lineage}.
     */
    private DefaultProcessStep processStep;

    /**
     * Creates the process step object if it does not already exists, then returns it.
     *
     * @return the process step (never {@code null}).
     */
    private DefaultProcessStep processStep() {
        if (processStep == null) {
            processStep = new DefaultProcessStep();
        }
        return processStep;
    }

    /**
     * Information about the procedures, processes and algorithms applied in the process step.
     * This is part of {@link #processStep}.
     */
    private DefaultProcessing processing;

    /**
     * Creates the processing object if it does not already exists, then returns it.
     *
     * @return the processing (never {@code null}).
     */
    private DefaultProcessing processing() {
        if (processing == null) {
            processing = new DefaultProcessing();
        }
        return processing;
    }

    /**
     * Adds the given element in the given collection if not already present. This method is used only for
     * properties that are usually stored in {@code List} rather than {@code Set} and for which we do not
     * keep a reference in this {@code MetadataBuilder} after the element has been added. This method is
     * intended for adding elements that despite being modifiable, are not going to be modified by this
     * {@code MetadataBuilder} class. Performance should not be a concern since the given list is usually
     * very short (0 or 1 element).
     *
     * <p>The given element should be non-null. The check for null value should be done by the caller instead
     * than by this method in order to avoid unneeded creation of collections. Such creation are implicitly
     * done by calls to {@code metadata.getFoos()} methods.</p>
     */
    private static <E> void addIfNotPresent(final Collection<E> collection, final E element) {
        if (!collection.contains(element)) {
            collection.add(element);
        }
    }

    /**
     * The type of party to create (individual, organization or unknown).
     */
    public enum PartyType {
        /**
         * Instructs {@link #newParty(PartyType)} that the next party to create should be an instance of
         * {@link DefaultIndividual}.
         */
        INDIVIDUAL,

        /**
         * Instructs {@link #newParty(PartyType)} that the next party to create should be an instance of
         * {@link DefaultOrganisation}.
         */
        ORGANISATION,

        /**
         * Instructs {@link #newParty(PartyType)} that the next party to create if of unknown type.
         */
        UNKNOWN
    }

    /**
     * Commits all pending information under the "responsible party" node (author, address, <i>etc</i>).
     * If there is no pending party information, then invoking this method has no effect
     * except setting the {@code type} flag.
     * If new party information are added after this method call, they will be stored in a new element.
     *
     * <p>This method does not need to be invoked unless a new "responsible party" node,
     * separated from the previous one, is desired.</p>
     *
     * @param  type  whether the party to create is an individual or an organization.
     */
    public final void newParty(final PartyType type) {
        ArgumentChecks.ensureNonNull("type", type);
        if (party != null) {
            addIfNotPresent(responsibility().getParties(), party);
            party = null;
        }
        partyType = type;
    }

    /**
     * Commits all pending information under the metadata "identification info" node (author, bounding box, <i>etc</i>).
     * If there is no pending identification information, then invoking this method has no effect.
     * If new identification info are added after this method call, they will be stored in a new element.
     *
     * <p>This method does not need to be invoked unless a new "identification info" node,
     * separated from the previous one, is desired.</p>
     */
    public final void newIdentification() {
        /*
         * Construction shall be ordered from children to parents.
         */
        newParty(PartyType.UNKNOWN);
        if (responsibility != null) {
            addIfNotPresent(citation().getCitedResponsibleParties(), responsibility);
            responsibility = null;
        }
        if (citation != null) {
            identification().setCitation(citation);
            citation = null;
        }
        if (extent != null) {
            addIfNotPresent(identification().getExtents(), extent);
            extent = null;
        }
        if (format != null) {
            addIfNotPresent(identification().getResourceFormats(), format);
            format = null;
        }
        if (constraints != null) {
            addIfNotPresent(identification().getResourceConstraints(), constraints);
            constraints = null;
        }
        if (identification != null) {
            addIfNotPresent(metadata().getIdentificationInfo(), identification);
            identification = null;
        }
    }

    /**
     * Commits all pending information under the metadata "acquisition" node (station, sensors, <i>etc</i>).
     * If there is no pending acquisition information, then invoking this method has no effect.
     * If new acquisition info are added after this method call, they will be stored in a new element.
     *
     * <p>This method does not need to be invoked unless a new "acquisition info" node,
     * separated from the previous one, is desired.</p>
     */
    public final void newAcquisition() {
        if (platform != null) {
            addIfNotPresent(acquisition().getPlatforms(), platform);
        }
        if (acquisition != null) {
            addIfNotPresent(metadata().getAcquisitionInformation(), acquisition);
            acquisition = null;
        }
    }

    /**
     * Commits all pending information under metadata "distribution" node.
     * If there is no pending distribution information, then invoking this method has no effect.
     * If new distribution info are added after this method call, they will be stored in a new element.
     *
     * <p>This method does not need to be invoked unless a new "distribution info" node,
     * separated from the previous one, is desired.</p>
     */
    public final void newDistribution() {
        if (distribution != null) {
            metadata().setDistributionInfo(distribution);
            distribution = null;
        }
    }

    /**
     * Commits all pending information under the metadata "feature catalog" node.
     * If there is no pending feature description, then invoking this method has no effect.
     * If new feature descriptions are added after this method call, they will be stored in a new element.
     *
     * <p>This method does not need to be invoked unless a new "feature catalog description" node is desired.</p>
     */
    public final void newFeatureTypes() {
        if (featureDescription != null) {
            addIfNotPresent(metadata().getContentInfo(), featureDescription);
            featureDescription = null;
        }
    }

    /**
     * Commits all pending information under the metadata "content info" node (bands, <i>etc</i>).
     * If there is no pending coverage description, then invoking this method has no effect
     * except setting the {@code electromagnetic} flag.
     * If new coverage descriptions are added after this method call, they will be stored in a new element.
     *
     * <p>This method does not need to be invoked unless a new "coverage description" node is desired,
     * or the {@code electromagnetic} flag needs to be set to {@code true}.</p>
     *
     * @param  electromagnetic  {@code true} if the next {@code CoverageDescription} to create
     *         will be a description of measurements in the electromagnetic spectrum.
     */
    public final void newCoverage(final boolean electromagnetic) {
        newSampleDimension();
        if (attributeGroup != null) {
            addIfNotPresent(coverageDescription().getAttributeGroups(), attributeGroup);
            attributeGroup = null;
        }
        if (coverageDescription != null) {
            addIfNotPresent(metadata().getContentInfo(), coverageDescription);
            coverageDescription = null;
        }
        this.electromagnetic = electromagnetic;
    }

    /**
     * Commits all pending information under the coverage "attribute group" node.
     * If there is no pending sample dimension description, then invoking this method has no effect.
     * If new sample dimensions are added after this method call, they will be stored in a new element.
     *
     * <p>This method does not need to be invoked unless a new "sample dimension" node is desired.</p>
     */
    public final void newSampleDimension() {
        if (sampleDimension != null) {
            addIfNotPresent(attributeGroup().getAttributes(), sampleDimension);
            sampleDimension = null;
        }
    }

    /**
     * The type of grid spatial representation (georectified, georeferenceable or unspecified).
     */
    public enum GridType {
        /**
         * Grid is an instance of {@link org.opengis.metadata.spatial.Georectified}.
         */
        GEORECTIFIED,

        /**
         * Grid is an instance of {@link org.opengis.metadata.spatial.Georeferenceable}.
         */
        GEOREFERENCEABLE,

        /**
         * Grid is neither georectified or georeferenceable.
         * A plain {@link org.opengis.metadata.spatial.GridSpatialRepresentation} instance will be used.
         */
        UNSPECIFIED
    }

    /**
     * Commits all pending information under the metadata "spatial representation" node (dimensions, <i>etc</i>).
     * If there is no pending spatial representation information, then invoking this method has no effect.
     * If new spatial representation info are added after this method call, they will be stored in a new element.
     *
     * <p>This method does not need to be invoked unless a new "spatial representation info" node,
     * separated from the previous one, is desired.</p>
     *
     * @param type  whether the next grid should be an instance of {@link DefaultGeorectified} or {@link DefaultGeoreferenceable}.
     */
    public final void newGridRepresentation(final GridType type) {
        ArgumentChecks.ensureNonNull("type", type);
        if (gridRepresentation != null) {
            final int n = gridRepresentation.getAxisDimensionProperties().size();
            if (n != 0) {
                gridRepresentation.setNumberOfDimensions(shared(n));
            }
            if (groundControlPoints != null && gridRepresentation instanceof DefaultGeoreferenceable) {
                addIfNotPresent(((DefaultGeoreferenceable) gridRepresentation).getGeolocationInformation(), groundControlPoints);
                groundControlPoints = null;
            }
            addIfNotPresent(metadata.getSpatialRepresentationInfo(), gridRepresentation);
            gridRepresentation = null;
        }
        gridType = type;
    }

    /**
     * Commits all pending information under the metadata "lineage" node (process steps, <i>etc</i>).
     * If there is no pending lineage information, then invoking this method has no effect.
     * If new lineage information are added after this method call, they will be stored in a new element.
     *
     * <p>This method does not need to be invoked unless a new "lineage" node,
     * separated from the previous one, is desired.</p>
     */
    public final void newLineage() {
        if (processing != null) {
            processStep().setProcessingInformation(processing);
            processing = null;
        }
        if (processStep != null) {
            addIfNotPresent(lineage().getProcessSteps(), processStep);
            processStep = null;
        }
        if (lineage != null) {
            addIfNotPresent(metadata().getResourceLineages(), lineage);
            lineage = null;
        }
    }

    /**
     * Creates or fetches a citation for the given title. The same citation may be shared by many metadata objects,
     * for example identifiers or groups of keywords. Current implementation creates a {@link DefaultCitation} for
     * the given title and caches the result. Future implementations may return predefined citation constants from
     * the SQL database when applicable.
     *
     * @param  title  the citation title, or {@code null} if none.
     * @return a (potentially shared) citation for the given title, or {@code null} if the given title was null.
     */
    private Citation sharedCitation(final InternationalString title) {
        if (title == null) return null;
        return (Citation) sharedValues.computeIfAbsent(title, k -> new DefaultCitation((CharSequence) k));
    }

    /**
     * Creates or fetches an identifier for the given authority and code. This method may query the metadata
     * database for fetching a more complete {@link Citation} for the given {@code authority}.
     * This method may return a shared {@code Identifier} instance.
     *
     * @param  authority  the authority tile, or {@code null} if none.
     * @param  code       the identifier code (mandatory).
     */
    private Identifier sharedIdentifier(final CharSequence authority, final String code) {
        final DefaultIdentifier id = new DefaultIdentifier(sharedCitation(trim(authority)), code);
        return (Identifier) sharedValues.getOrDefault(id, id);
    }

    /**
     * Specify if an information apply to data, to metadata or to both.
     * This is used for setting the locale or character encoding.
     */
    public enum Scope {
        /**
         * Information applies only to the resource (data).
         */
        RESOURCE,

        /**
         * Information applies only to metadata.
         */
        METADATA,

        /**
         * Information applies to both resource and metadata.
         */
        ALL
    }

    /**
     * Adds a data and/or metadata identifier. This method performs the same work than
     * {@link #addIdentifier(CharSequence, String, Scope)} for situations where the
     * identifier instance is already available.
     *
     * @param  id     the identifier, or {@code null} if none.
     * @param  scope  whether the date applies to data, to metadata or to both.
     *
     * @see #addIdentifier(CharSequence, String, Scope)
     */
    public final void addIdentifier(Identifier id, final Scope scope) {
        ArgumentChecks.ensureNonNull("scope", scope);
        if (id != null) {
            id = (Identifier) sharedValues.getOrDefault(id, id);
            if (scope != Scope.RESOURCE) metadata().setMetadataIdentifier(id);
            if (scope != Scope.METADATA) addIfNotPresent(citation().getIdentifiers(), id);
        }
    }

    /**
     * Adds a resource (data) identifier, a metadata identifier, or both as they are often the same.
     * The identifier is added only if {@code code} is non-null, regardless other argument values.
     * Empty strings (ignoring spaces) are ignored.
     * Storages locations are:
     *
     * <ul>
     *   <li><b>Metadata:</b> {@code metadata/metadataIdentifier}</li>
     *   <li><b>Resource:</b> {@code metadata/identificationInfo/citation/identifier}</li>
     * </ul>
     *
     * @param  authority  the the person or party responsible for maintenance of the namespace, or {@code null} if none.
     * @param  code       the identifier code, or {@code null} for no-operation.
     * @param  scope      whether the date applies to data, to metadata or to both.
     *
     * @see #addTitle(CharSequence)
     * @see #addTitleOrIdentifier(String, Scope)
     * @see #addIdentifier(Identifier, Scope)
     */
    public final void addIdentifier(final CharSequence authority, String code, final Scope scope) {
        ArgumentChecks.ensureNonNull("scope", scope);
        if (code != null && !(code = code.trim()).isEmpty()) {
            final Identifier id = sharedIdentifier(authority, code);
            if (scope != Scope.RESOURCE) metadata().setMetadataIdentifier(id);
            if (scope != Scope.METADATA) addIfNotPresent(citation().getIdentifiers(), id);
        }
    }

    /**
     * Sets the file format. The given name should be a short name like "GeoTIFF".
     * The long name will be inferred from the given short name, if possible.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/resourceFormat}</li>
     * </ul>
     *
     * This method should be invoked <strong>before</strong> any other method writing in the
     * {@code identificationInfo/resourceFormat} node. If this exception throws an exception,
     * than that exception should be reported as a warning. Example:
     *
     * {@preformat java
     *     try {
     *         metadata.setFormat("MyFormat");
     *     } catch (MetadataStoreException e) {
     *         metadata.addFormatName("MyFormat");
     *         listeners.warning(null, e);
     *     }
     *     metadata.addCompression("decompression technique");
     * }
     *
     * @param  abbreviation  the format short name or abbreviation, or {@code null} for no-operation.
     * @throws MetadataStoreException  if this method can not connect to the {@code jdbc/SpatialMetadata} database.
     *         Callers should generally handle this exception as a recoverable one (i.e. log a warning and continue).
     *
     * @see #addCompression(CharSequence)
     * @see #addFormatName(CharSequence)
     */
    public final void setFormat(final String abbreviation) throws MetadataStoreException {
        if (abbreviation != null && abbreviation.length() != 0) {
            if (format == null) {
                format = MetadataSource.getProvided().lookup(Format.class, abbreviation);
                /*
                 * Additional step for converting deprecated "name" and "specification" into non-deprecated properties.
                 * This step is not required on SIS branches that depend on development branches of GeoAPI 3.1 or 4.0.
                 */
                format = DefaultFormat.castOrCopy(format);
            } else {
                addFormatName(abbreviation);
            }
        }
    }

    /**
     * Adds a language used for documenting data and/or metadata.
     * Storage locations are:
     *
     * <ul>
     *   <li><b>Metadata:</b> {@code metadata/language}</li>
     *   <li><b>Resource:</b> {@code metadata/identificationInfo/language}</li>
     * </ul>
     *
     * @param  language  a language used for documenting data and/or metadata, or {@code null} for no-operation.
     * @param  scope     whether the language applies to data, to metadata or to both.
     *
     * @see #addEncoding(Charset, MetadataBuilder.Scope)
     */
    public final void addLanguage(final Locale language, final Scope scope) {
        ArgumentChecks.ensureNonNull("scope", scope);
        if (language != null) {
            // No need to use 'addIfNotPresent(…)' because Locale collection is a Set by default.
            if (scope != Scope.RESOURCE) metadata().getLanguages().add(language);
            if (scope != Scope.METADATA) identification().getLanguages().add(language);
        }
    }

    /**
     * Adds a character set used for encoding the data and/or metadata.
     * Storage locations are:
     *
     * <ul>
     *   <li><b>Metadata:</b> {@code metadata/characterSet}</li>
     *   <li><b>Resource:</b> {@code metadata/identificationInfo/characterSet}</li>
     * </ul>
     *
     * @param  encoding  the character set used for encoding data and/or metadata, or {@code null} for no-operation.
     * @param  scope     whether the encoding applies to data, to metadata or to both.
     *
     * @see #addLanguage(Locale, MetadataBuilder.Scope)
     */
    public final void addEncoding(final Charset encoding, final Scope scope) {
        ArgumentChecks.ensureNonNull("scope", scope);
        if (encoding != null) {
            // No need to use 'addIfNotPresent(…)' because Charset collection is a Set by default.
            if (scope != Scope.RESOURCE) metadata().getCharacterSets().add(encoding);
            if (scope != Scope.METADATA) identification().getCharacterSets().add(
                    Types.forCodeName(CharacterSet.class, encoding.toString(), true));
        }
    }

    /**
     * Adds information about the scope of the resource.
     * The scope is typically {@link ScopeCode#DATASET}.
     * Storage locations are:
     *
     * <ul>
     *   <li>{@code metadata/metadataScope/resourceScope}</li>
     *   <li>{@code metadata/metadataScope/name}</li>
     * </ul>
     *
     * @param  scope  the scope of the resource, or {@code null} if none.
     * @param  name   description of the scope, or {@code null} if none.
     */
    public final void addResourceScope(final ScopeCode scope, final CharSequence name) {
        if (scope != null || name != null) {
            addIfNotPresent(metadata().getMetadataScopes(), new DefaultMetadataScope(scope, name));
        }
    }

    /**
     * Adds a date of the given type. This is not the data acquisition time, but rather the metadata creation
     * or last update time. With {@link Scope#METADATA}, this is the creation of the metadata file as a whole.
     * With {@link Scope#RESOURCE}, this is the creation of the metadata for a particular "identification info".
     * They are often the same, since there is typically only one "identification info" per file.
     * Storage locations are:
     *
     * <ul>
     *   <li><b>Metadata:</b> {@code metadata/dateInfo/*}</li>
     *   <li><b>Resource:</b> {@code metadata/identificationInfo/citation/date/*}</li>
     * </ul>
     *
     * If a date already exists for the given type, then the earliest date is retained (oldest date are discarded)
     * except for {@code DateType.LAST_REVISION}, {@code DateType.LAST_UPDATE LAST_UPDATE} or any other date type
     * prefixed by {@code "LATE_"}, where only the latest date is kept.
     *
     * @param  date   the date to add, or {@code null} for no-operation..
     * @param  type   the type of the date to add, or {@code null} if none (not legal but tolerated).
     * @param  scope  whether the date applies to data, to metadata or to both.
     *
     * @see #addAcquisitionTime(Date)
     */
    public final void addCitationDate(final Date date, final DateType type, final Scope scope) {
        ArgumentChecks.ensureNonNull("scope", scope);
        if (date != null) {
            final DefaultCitationDate cd = new DefaultCitationDate(date, type);
            if (scope != Scope.RESOURCE) addEarliest(metadata().getDateInfo(), cd, type);
            if (scope != Scope.METADATA) addEarliest(citation().getDates(),    cd, type);
        }
    }

    /**
     * Adds a date in the given collection, making sure that there is no two dates of the same type.
     * If two dates are of the same type, retains the latest one if the type name starts with {@code "LATE_"}
     * or retains the earliest date otherwise.
     */
    private static void addEarliest(final Collection<CitationDate> dates, final CitationDate cd, final DateType type) {
        for (final Iterator<CitationDate> it = dates.iterator(); it.hasNext();) {
            final CitationDate co = it.next();
            if (type.equals(co.getDateType())) {
                final Date oldDate = co.getDate();
                final Date newDate = cd.getDate();
                if (type.name().startsWith("LATE_") ? oldDate.before(newDate) : oldDate.after(newDate)) {
                    it.remove();
                    break;
                }
                return;
            }
        }
        dates.add(cd);
    }

    /**
     * Returns the given character sequence as a non-empty character string with leading and trailing spaces removed.
     * If the given character sequence is null, empty or blank, then this method returns {@code null}.
     */
    private static InternationalString trim(CharSequence value) {
        value = CharSequences.trimWhitespaces(value);
        if (value != null && value.length() != 0) {
            return Types.toInternationalString(value);
        } else {
            return null;
        }
    }

    /**
     * Returns the concatenation of the given strings. The previous string may be {@code null}.
     * This method does nothing if the previous string already contains the one to append.
     */
    private static InternationalString append(final InternationalString previous, final InternationalString toAdd) {
        if (previous == null) {
            return toAdd;
        }
        final String p = previous.toString();
        final String a = toAdd.toString();
        if (p.contains(a)) {
            return previous;
        }
        return Types.toInternationalString(p + System.lineSeparator() + a);
    }

    /**
     * Returns {@code true} if the given character sequences have equal content.
     */
    private static boolean equals(final CharSequence s1, final CharSequence s2) {
        if (s1 == s2) {
            return true;
        }
        if (s1 == null || s2 == null) {
            return false;
        }
        return s1.toString().equals(s2.toString());
    }

    /**
     * Adds a title or alternate title of the resource.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/citation/title} if not yet used</li>
     *   <li>{@code metadata/identificationInfo/citation/alternateTitle} otherwise</li>
     * </ul>
     *
     * @param title  the resource title or alternate title, or {@code null} for no-operation.
     *
     * @see #addAbstract(CharSequence)
     */
    public final void addTitle(final CharSequence title) {
        final InternationalString i18n = trim(title);
        if (i18n != null) {
            final DefaultCitation citation = citation();
            final InternationalString current = citation.getTitle();
            if (current == null) {
                citation.setTitle(i18n);
            } else if (!equals(current, i18n)) {
                addIfNotPresent(citation.getAlternateTitles(), i18n);
            }
        }
    }

    /**
     * Adds the given code as a title if the current citation has no title, or as an identifier otherwise.
     * This method is invoked when adding an identifier to a metadata that may have no title. Because the
     * title is mandatory, adding only an identifier would make an invalid metadata.
     *
     * @param  code   the identifier code, or {@code null} for no-operation.
     * @param  scope  whether the date applies to data, to metadata or to both.
     *
     * @see #addTitle(CharSequence)
     * @see #addIdentifier(CharSequence, String, Scope)
     */
    public final void addTitleOrIdentifier(final String code, Scope scope) {
        ArgumentChecks.ensureNonNull("scope", scope);
        if (scope != Scope.METADATA) {
            if (citation == null || citation.getTitle() == null) {
                addTitle(code);
                if (scope == Scope.RESOURCE) {
                    return;
                }
                scope = Scope.METADATA;
            }
        }
        addIdentifier(null, code, scope);
    }

    /**
     * Adds a version of the resource.
     * If a version already exists, the new one will be appended after a new line.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/citation/edition}</li>
     * </ul>
     *
     * @param version  the version of resource(s), or {@code null} for no-operation.
     */
    public final void addEdition(final CharSequence version) {
        final InternationalString i18n = trim(version);
        if (i18n != null) {
            final DefaultCitation citation = citation();
            citation.setEdition(append(citation.getEdition(), i18n));
        }
    }

    /**
     * Adds a brief narrative summary of the resource(s).
     * If a summary already exists, the new one will be appended after a new line.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/abstract}</li>
     * </ul>
     *
     * @param description  the summary of resource(s), or {@code null} for no-operation.
     *
     * @see #addTitle(CharSequence)
     * @see #addPurpose(CharSequence)
     */
    public final void addAbstract(final CharSequence description) {
        final InternationalString i18n = trim(description);
        if (i18n != null) {
            final DefaultDataIdentification identification = identification();
            identification.setAbstract(append(identification.getAbstract(), i18n));
        }
    }

    /**
     * Adds a summary of the intentions with which the resource(s) was developed.
     * If a purpose already exists, the new one will be appended after a new line.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/purpose}</li>
     * </ul>
     *
     * @param intention  the summary of intention(s), or {@code null} for no-operation.
     *
     * @see #addAbstract(CharSequence)
     */
    public final void addPurpose(final CharSequence intention) {
        final InternationalString i18n = trim(intention);
        if (i18n != null) {
            final DefaultDataIdentification identification = identification();
            identification.setPurpose(append(identification.getPurpose(), i18n));
        }
    }

    /**
     * Adds other information required to complete the citation that is not recorded elsewhere.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/citation/otherCitationDetails}</li>
     * </ul>
     *
     * @param  details  other details, or {@code null} for no-operation.
     */
    public final void addOtherCitationDetails(final CharSequence details) {
        final InternationalString i18n = trim(details);
        if (i18n != null) {
            final DefaultCitation citation = citation();
            citation.setOtherCitationDetails(append(citation.getOtherCitationDetails(), i18n));
        }
    }

    /**
     * Adds any other descriptive information about the resource.
     * If information already exists, the new one will be appended after a new line.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/supplementalInformation}</li>
     * </ul>
     *
     * @param info  any other descriptive information about the resource, or {@code null} for no-operation.
     */
    public final void addSupplementalInformation(final CharSequence info) {
        final InternationalString i18n = trim(info);
        if (i18n != null) {
            final DefaultDataIdentification identification = identification();
            identification.setSupplementalInformation(append(identification.getSupplementalInformation(), i18n));
        }
    }

    /**
     * Adds a main theme of the resource.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/topicCategory}</li>
     * </ul>
     *
     * @param  topic  main theme of the resource, or {@code null} for no-operation.
     */
    public final void addTopicCategory(final TopicCategory topic) {
        if (topic != null) {
            // No need to use 'addIfNotPresent(…)' for enumerations.
            identification().getTopicCategories().add(topic);
        }
    }

    /**
     * Adds keywords if at least one non-empty element exists in the {@code keywords} array.
     * Other arguments have no impact on whether keywords are added or not because only the
     * {@code MD_Keywords.keyword} property is mandatory.
     * Storage locations are:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/descriptiveKeywords}</li>
     *   <li>{@code metadata/identificationInfo/thesaurusName/title}</li>
     *   <li>{@code metadata/identificationInfo/type}</li>
     * </ul>
     *
     * @param  keywords       word(s) used to describe the subject, or {@code null} for no-operation.
     * @param  type           subject matter used to group similar keywords, or {@code null} if none.
     * @param  thesaurusName  name of the formally registered thesaurus, or {@code null} if none.
     */
    public final void addKeywords(final Iterable<? extends CharSequence> keywords, final KeywordType type,
            final CharSequence thesaurusName)
    {
        if (keywords != null) {
            DefaultKeywords group = null;
            Collection<InternationalString> list = null;
            for (final CharSequence kw : keywords) {
                final InternationalString i18n = trim(kw);
                if (i18n != null) {
                    if (list == null) {
                        group = new DefaultKeywords();
                        group.setType(type);
                        group.setThesaurusName(sharedCitation(trim(thesaurusName)));
                        list = group.getKeywords();
                    }
                    list.add(i18n);
                }
            }
            if (group != null) {
                addIfNotPresent(identification().getDescriptiveKeywords(), group);
            }
        }
    }

    /**
     * Adds an author name. If an author was already defined with a different name,
     * then a new party instance is created.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/citation/party/name}</li>
     * </ul>
     *
     * @param  name  the name of the author or publisher, or {@code null} for no-operation.
     */
    public final void addAuthor(final CharSequence name) {
        final InternationalString i18n = trim(name);
        if (i18n != null) {
            if (party != null) {
                final InternationalString current = party.getName();
                if (current != null) {
                    if (equals(current, name)) {
                        return;
                    }
                    newParty(partyType);
                }
            }
            party().setName(i18n);
        }
    }

    /**
     * Adds role, name, contact and position information for an individual or organization that is responsible
     * for the resource. This method can be used as an alternative to {@link #addAuthor(CharSequence)} when the
     * caller needs to create the responsibly party itself.
     *
     * <p>If the given {@code role} is non-null, then this method will ensure that the added party has the given
     * role. A copy of the given party will be created if needed (the given party will never be modified).</p>
     *
     * Storage locations are:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/citation/citedResponsibleParty}</li>
     *   <li>{@code metadata/identificationInfo/citation/citedResponsibleParty/role}</li>
     * </ul>
     *
     * @param  party  the individual or organization that is responsible, or {@code null} for no-operation.
     * @param  role   the role to set, or {@code null} for leaving it unchanged.
     */
    public final void addCitedResponsibleParty(ResponsibleParty party, final Role role) {
        if (party != null) {
            if (role != null && !role.equals(party.getRole())) {
                party = new DefaultResponsibleParty(party);
                ((DefaultResponsibility) party).setRole(role);
            }
            addIfNotPresent(citation().getCitedResponsibleParties(), party);
        }
    }

    /**
     * Adds a means of communication with person(s) and organizations(s) associated with the resource(s).
     * This is often the same party than the above cited responsibly party, with only the role changed.
     * Storage locations are:
     *
     * <ul>
     *   <li><b>Metadata</b> {@code metadata/contact}</li>
     *   <li><b>Resource</b> {@code metadata/identificationInfo/pointOfContact}</li>
     * </ul>
     *
     * @param  contact  means of communication with party associated with the resource, or {@code null} for no-operation.
     * @param  scope    whether the contact applies to data, to metadata or to both.
     */
    public final void addPointOfContact(final ResponsibleParty contact, final Scope scope) {
        ArgumentChecks.ensureNonNull("scope", scope);
        if (contact != null) {
            if (scope != Scope.RESOURCE)     addIfNotPresent(metadata().getContacts(), contact);
            if (scope != Scope.METADATA) addIfNotPresent(identification().getPointOfContacts(), contact);
        }
    }

    /**
     * Adds a distributor. This is often the same than the above responsible party.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/distributionInfo/distributor/distributorContact}</li>
     * </ul>
     *
     * @param  distributor  the distributor, or {@code null} for no-operation.
     */
    public final void addDistributor(final ResponsibleParty distributor) {
        if (distributor != null) {
            addIfNotPresent(distribution().getDistributors(), new DefaultDistributor(distributor));
        }
    }

    /**
     * Adds recognition of those who contributed to the resource(s).
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/credit}</li>
     * </ul>
     *
     * @param  credit  recognition of those who contributed to the resource, or {@code null} for no-operation.
     */
    public final void addCredits(final CharSequence credit) {
        if (credit != null) {
            final String c = CharSequences.trimWhitespaces(credit).toString();
            if (!c.isEmpty()) {
                addIfNotPresent(identification().getCredits(), c);
            }
        }
    }

    /**
     * Elements to omit in the legal notice to be parsed by {@link MetadataBuilder#parseLegalNotice(String)}.
     * Some of those elements are implied by the metadata were the legal notice will be stored.
     */
    private static final class LegalSymbols {
        /**
         * Symbols associated to restrictions.
         */
        private static final LegalSymbols[] VALUES = {
            new LegalSymbols(Restriction.COPYRIGHT, "COPYRIGHT", "(C)", "©", "All rights reserved"),
            new LegalSymbols(Restriction.TRADEMARK, "TRADEMARK", "(TM)", "™", "(R)", "®")
        };

        /**
         * The restriction to use if an item in the {@linkplain #symbols} list is found.
         */
        private final Restriction restriction;

        /**
         * Symbols to use as an indication that the {@linkplain #restriction} applies.
         */
        private final String[] symbols;

        /**
         * Creates a new enumeration value for the given symbol.
         */
        private LegalSymbols(final Restriction restriction, final String... symbols) {
            this.restriction = restriction;
            this.symbols = symbols;
        }

        /**
         * Returns {@code true} if the given character is a space or a punctuation of category "other".
         * The punctuation characters include coma, dot, semi-colon, <i>etc.</i> but do not include
         * parenthesis or connecting punctuation.
         *
         * @param c the Unicode code point of the character to test.
         */
        private static boolean isSpaceOrPunctuation(final int c) {
            switch (Character.getType(c)) {
                case Character.LINE_SEPARATOR:
                case Character.SPACE_SEPARATOR:
                case Character.PARAGRAPH_SEPARATOR:
                case Character.OTHER_PUNCTUATION: return true;
                default: return false;
            }
        }

        /**
         * Implementation of {@link MetadataBuilder#parseLegalNotice(String)}, provided here for reducing
         * the amount of class loading in the common case where there is no legal notice to parse.
         */
        static void parse(final String notice, final DefaultLegalConstraints constraints) {
            final int length = notice.length();
            final StringBuilder buffer = new StringBuilder(length);
            int     year           = 0;         // The copyright year, or 0 if none.
            int     quoteLevel     = 0;         // Incremented on ( [ « characters, decremented on ) ] » characters.
            boolean isCopyright    = false;     // Whether the word parsed by previous iteration was "Copyright" or "(C)".
            boolean wasSeparator   = true;      // Whether the caracter parsed by the previous iteration was a word separator.
            boolean wasPunctuation = true;      // Whether the previous character was a punctuation of Unicode category "other".
            boolean skipNextChars  = true;      // Whether the next spaces and some punction characters should be ignored.
parse:      for (int i = 0; i < length;) {
                final int c = notice.codePointAt(i);
                final int n = Character.charCount(c);
                int     quoteChange   = 0;
                boolean isSeparator   = false;
                boolean isPunctuation;
                switch (Character.getType(c)) {
                    case Character.INITIAL_QUOTE_PUNCTUATION:
                    case Character.START_PUNCTUATION: {
                        quoteChange   = +1;                     //  ( [ «  etc.
                        skipNextChars = false;
                        isPunctuation = false;
                        break;
                    }
                    case Character.FINAL_QUOTE_PUNCTUATION:
                    case Character.END_PUNCTUATION: {
                        quoteChange   = -1;                     //  ) ] »  etc.
                        skipNextChars = false;
                        isPunctuation = false;
                        break;
                    }
                    default: {                                  // Letter, digit, hyphen, etc.
                        skipNextChars = false;
                        isPunctuation = false;
                        break;
                    }
                    case Character.OTHER_PUNCTUATION: {         //  , . : ; / " etc. but not -.
                        isPunctuation = true;
                        isSeparator   = true;
                        break;
                    }
                    case Character.LINE_SEPARATOR:
                    case Character.SPACE_SEPARATOR:
                    case Character.PARAGRAPH_SEPARATOR: {
                        isPunctuation = wasPunctuation;
                        isSeparator   = true;
                        break;
                    }
                }
                if (wasSeparator && !isSeparator && quoteLevel == 0) {
                    /*
                     * Found the beginning of a new word. Ignore textes like "(C)" or "All rights reserved".
                     * Some of those textes are implied by the metadata where the legal notice will be stored.
                     */
                    for (final LegalSymbols r : VALUES) {
                        for (final String symbol : r.symbols) {
                            if (notice.regionMatches(true, i, symbol, 0, symbol.length())) {
                                final int after = i + symbol.length();
                                if (after >= length || isSpaceOrPunctuation(notice.codePointAt(after))) {
                                    isCopyright |= Restriction.COPYRIGHT.equals(r.restriction);
                                    constraints.getUseConstraints().add(r.restriction);
                                    wasPunctuation = true;      // Pretend that "Copyright" was followed by a coma.
                                    skipNextChars  = true;      // Ignore spaces and punctuations until the next word.
                                    i = after;                  // Skip the "Copyright" (or other) word.
                                    continue parse;
                                }
                            }
                        }
                    }
                    /*
                     * If a copyright notice is followed by digits, assume that those digits are the copyright year.
                     * We require the year is followed by punctuations or non-breaking space in order to reduce the
                     * risk of confusion with postal addresses. So this block should accept "John, 1992." but not
                     * "1992-1 Nowhere road".
                     */
                    if (isCopyright && wasPunctuation && year == 0 && c >= '0' && c <= '9') {
                        int endOfDigits = i + n;            // After the last digit in sequence.
                        while (endOfDigits < length) {
                            final int d = notice.codePointAt(endOfDigits);
                            if (d < '0' || d > '9') break;
                            endOfDigits++;              // No need to use Character.charCount(s) here.
                        }
                        // Verify if the digits are followed by a punctuation.
                        final int endOfToken = CharSequences.skipLeadingWhitespaces(notice, endOfDigits, length);
                        if (endOfToken > endOfDigits || isSpaceOrPunctuation(notice.codePointAt(endOfToken))) try {
                            year = Integer.parseInt(notice.substring(i, endOfDigits));
                            if (year >= 1800 && year <= 9999) {                     // Those limits are arbitrary.
                                skipNextChars = true;
                                i = endOfToken;
                                continue;
                            }
                            year = 0;                                               // Reject as not a copyright year.
                        } catch (NumberFormatException e) {
                            // Not an integer - ignore, will be handled as text.
                        }
                    }
                }
                /*
                 * End of the block that was executed at the beginning of each new word.
                 * Following is executed for every characters, except if the above block
                 * skipped a portion of the input string.
                 */
                wasPunctuation = isPunctuation;
                wasSeparator   = isSeparator;
                quoteLevel    += quoteChange;
                if (!skipNextChars && !Character.isIdentifierIgnorable(c)) {
                    buffer.appendCodePoint(c);
                }
                i += n;
            }
            /*
             * End of parsing. Omit trailing spaces and some punctuations if any, then store the result.
             */
            int i = buffer.length();
            while (i > 0) {
                final int c = buffer.codePointBefore(i);
                if (!isSpaceOrPunctuation(c)) break;
                i -= Character.charCount(c);
            }
            final DefaultCitation c = new DefaultCitation(notice);
            if (year != 0) {
                final Date date = new Date(LocalDate.of(year, 1, 1).toEpochDay() * MILLISECONDS_PER_DAY);
                c.setDates(Collections.singleton(new DefaultCitationDate(date, DateType.valueOf("IN_FORCE"))));
            }
            if (i != 0) {
                buffer.setLength(i);
                // Same limitation than MetadataBuilder.party().
                final AbstractParty party = new AbstractParty(buffer, null);
                final DefaultResponsibleParty r = new DefaultResponsibleParty(Role.OWNER);
                r.setParties(Collections.singleton(party));
                c.setCitedResponsibleParties(Collections.singleton(r));
            }
            constraints.getReferences().add(c);
        }
    }

    /**
     * Parses the legal notice. The method expects a string of the form
     * “Copyright, John Smith, 1992. All rights reserved.”
     * The result of above example will be:
     *
     * {@preformat text
     *   Metadata
     *     └─Identification info
     *         └─Resource constraints
     *             ├─Use constraints……………………………… Copyright
     *             └─Reference
     *                 ├─Title……………………………………………… Copyright (C), John Smith, 1992. All rights reserved.
     *                 ├─Date
     *                 │   ├─Date……………………………………… 1992-01-01
     *                 │   └─Date type………………………… In force
     *                 └─Cited responsible party
     *                     ├─Party
     *                     │   └─Name…………………………… John Smith
     *                     └─Role……………………………………… Owner
     * }
     *
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/resourceConstraint}</li>
     * </ul>
     *
     * @param  notice  the legal notice, or {@code null} for no-operation.
     */
    public final void parseLegalNotice(final String notice) {
        if (notice != null) {
            LegalSymbols.parse(notice, constraints());
        }
    }

    /**
     * Adds an access constraint applied to assure the protection of privacy or intellectual property,
     * and any special restrictions or limitations on obtaining the resource.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/resourceConstraint/accessConstraints}</li>
     * </ul>
     *
     * @param  restriction  access constraints applied, or {@code null} for no-operation.
     */
    public final void addAccessConstraint(final Restriction restriction) {
        if (restriction != null) {
            // No need to use 'addIfNotPresent(…)' for code lists.
            constraints().getAccessConstraints().add(restriction);
        }
    }

    /**
     * Adds a limitation affecting the fitness for use of the resource.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/resourceConstraint/useLimitation}</li>
     * </ul>
     *
     * @param  limitation  limitation affecting the fitness for use, or {@code null} for no-operation.
     */
    public final void addUseLimitation(final CharSequence limitation) {
        final InternationalString i18n = trim(limitation);
        if (i18n != null) {
            addIfNotPresent(constraints().getUseLimitations(), i18n);
        }
    }

    /**
     * Adds the given coordinate reference system to metadata, if it does not already exists.
     * This method ensures that there is no duplicated values.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/referenceSystemInfo}</li>
     * </ul>
     *
     * @param  crs  the coordinate reference system to add to the metadata, or {@code null} for no-operation.
     */
    public final void addReferenceSystem(final ReferenceSystem crs) {
        if (crs != null) {
            addIfNotPresent(metadata().getReferenceSystemInfo(), crs);
        }
    }

    /**
     * Adds the given geometry as a bounding polygon.
     * Storage locations is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/extent/geographicElement/polygon}</li>
     * </ul>
     *
     * @param  bounds  the bounding polygon, or {@code null} if none.
     */
    public final void addBoundingPolygon(final Geometry bounds) {
        if (bounds != null) {
            addIfNotPresent(extent().getGeographicElements(), new DefaultBoundingPolygon(bounds));
        }
    }

    /**
     * Adds a geographic extent described by an identifier. The given identifier is stored as-is as
     * the natural language description, and possibly in a modified form as the geographic identifier.
     * See {@link DefaultGeographicDescription#DefaultGeographicDescription(CharSequence)} for details.
     * Storage locations are:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/extent/geographicElement/description}</li>
     *   <li>{@code metadata/identificationInfo/extent/geographicElement/identifier}</li>
     * </ul>
     *
     * @param  identifier  identifier or description of spatial and temporal extent, or {@code null} for no-operation.
     */
    public final void addExtent(final CharSequence identifier) {
        final InternationalString i18n = trim(identifier);
        if (i18n != null) {
            addIfNotPresent(extent().getGeographicElements(), new DefaultGeographicDescription(identifier));
        }
    }

    /**
     * Adds the given envelope, including its CRS, to the metadata. If the metadata already contains a geographic
     * bounding box, then a new bounding box is added; this method does not compute the union of the two boxes.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/extent/geographicElement}</li>
     * </ul>
     *
     * @param  envelope  the extent to add in the metadata, or {@code null} for no-operation.
     * @throws TransformException if an error occurred while converting the given envelope to extents.
     */
    public final void addExtent(final Envelope envelope) throws TransformException {
        if (envelope != null) {
            addReferenceSystem(envelope.getCoordinateReferenceSystem());
            if (!(envelope instanceof AbstractEnvelope && ((AbstractEnvelope) envelope).isAllNaN())) {
                extent().addElements(envelope);
            }
        }
    }

    /**
     * Adds a geographic bounding box initialized to the values in the given array.
     * The array must contains at least 4 values starting at the given index in this exact order:
     *
     * <ul>
     *   <li>{@code westBoundLongitude} (the minimal λ value), or {@code NaN}</li>
     *   <li>{@code eastBoundLongitude} (the maximal λ value), or {@code NaN}</li>
     *   <li>{@code southBoundLatitude} (the minimal φ value), or {@code NaN}</li>
     *   <li>{@code northBoundLatitude} (the maximal φ value), or {@code NaN}</li>
     * </ul>
     *
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/extent/geographicElement}</li>
     * </ul>
     *
     * @param  coordinates  the geographic coordinates, or {@code null} for no-operation.
     * @param  index        index of the first value to use in the given array.
     */
    public final void addExtent(final double[] coordinates, int index) {
        if (coordinates != null) {
            final DefaultGeographicBoundingBox bbox = new DefaultGeographicBoundingBox(
                        coordinates[index], coordinates[++index], coordinates[++index], coordinates[++index]);
            if (!bbox.isEmpty()) {
                addIfNotPresent(extent().getGeographicElements(), bbox);
            }
        }
    }

    /**
     * Adds a vertical extent covered by the data.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/extent/verticalElement}</li>
     * </ul>
     *
     * @param  minimumValue  the lowest vertical extent contained in the dataset, or {@link Double#NaN} if none.
     * @param  maximumValue  the highest vertical extent contained in the dataset, or {@link Double#NaN} if none.
     * @param  verticalCRS   the information about the vertical coordinate reference system, or {@code null}.
     */
    public final void addVerticalExtent(final double minimumValue,
                                        final double maximumValue,
                                        final VerticalCRS verticalCRS)
    {
        if (!Double.isNaN(minimumValue) || !Double.isNaN(maximumValue) || verticalCRS != null) {
            addIfNotPresent(extent().getVerticalElements(),
                            new DefaultVerticalExtent(minimumValue, maximumValue, verticalCRS));
        }
    }

    /**
     * Adds a temporal extent covered by the data.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/extent/temporalElement}</li>
     * </ul>
     *
     * @param  startTime  when the data begins, or {@code null} if unbounded.
     * @param  endTime    when the data ends, or {@code null} if unbounded.
     * @throws UnsupportedOperationException if the temporal module is not on the classpath.
     *
     * @see #addAcquisitionTime(Date)
     */
    public final void addTemporalExtent(final Date startTime, final Date endTime) {
        if (startTime != null || endTime != null) {
            final DefaultTemporalExtent t = new DefaultTemporalExtent();
            t.setBounds(startTime, endTime);
            addIfNotPresent(extent().getTemporalElements(), t);
        }
    }

    /**
     * Adds descriptions for the given feature.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/featureTypes/featureTypeName}</li>
     *   <li>{@code metadata/contentInfo/featureTypes/featureInstanceCount}</li>
     * </ul>
     *
     * This method returns the feature name for more convenient chaining with
     * {@link org.apache.sis.storage.FeatureNaming#add FeatureNaming.add(…)}.
     * Note that the {@link FeatureCatalogBuilder} subclasses can also be used for that chaining.
     *
     * @param  type         the feature type to add, or {@code null} for no-operation.
     * @param  occurrences  number of instances of the given feature type, or a negative value if unknown.
     * @return the name of the added feature, or {@code null} if none.
     *
     * @see FeatureCatalogBuilder#define(DefaultFeatureType)
     */
    public final GenericName addFeatureType(final DefaultFeatureType type, final long occurrences) {
        if (type != null) {
            final GenericName name = type.getName();
            if (name != null) {
                final DefaultFeatureTypeInfo info = new DefaultFeatureTypeInfo(name);
                if (occurrences >= 0) {
                    info.setFeatureInstanceCount(shared((int) Math.min(occurrences, Integer.MAX_VALUE)));
                }
                addIfNotPresent(featureDescription().getFeatureTypeInfo(), info);
                return name;
            }
        }
        return null;
    }

    /**
     * Adds a method used to spatially represent geographic information.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/spatialRepresentationType}</li>
     * </ul>
     *
     * @param  type  method used to spatially represent geographic information, or {@code null} for no-operation.
     */
    public final void addSpatialRepresentation(final SpatialRepresentationType type) {
        if (type != null) {
            // No need to use 'addIfNotPresent(…)' for code lists.
            identification().getSpatialRepresentationTypes().add(type);
        }
    }

    /**
     * Adds and populates a "spatial representation info" node using the given grid geometry.
     * This method invokes implicitly {@link #newGridRepresentation(GridType)}, unless this
     * method returns {@code false} in which case nothing has been done.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/spatialRepresentationInfo/transformationDimensionDescription}</li>
     *   <li>{@code metadata/spatialRepresentationInfo/transformationParameterAvailability}</li>
     *   <li>{@code metadata/spatialRepresentationInfo/axisDimensionProperties/dimensionName}</li>
     *   <li>{@code metadata/spatialRepresentationInfo/axisDimensionProperties/dimensionSize}</li>
     *   <li>{@code metadata/spatialRepresentationInfo/axisDimensionProperties/resolution}</li>
     *   <li>{@code metadata/identificationInfo/spatialRepresentationType}</li>
     *   <li>{@code metadata/referenceSystemInfo}</li>
     * </ul>
     *
     * This method does not add the envelope provided by {@link GridGeometry#getEnvelope()}.
     * That envelope appears in a separated node, which can be added by {@link #addExtent(Envelope)}.
     * This separation is required by {@link AbstractGridResource} for instance.
     *
     * @param  description    a general description of the "grid to CRS" transformation, or {@code null} if none.
     *                        Can also be specified later by a call to {@link #setGridToCRS(CharSequence)}.
     * @param  grid           the grid extent, "grid to CRS" transform and target CRS, or {@code null} if none.
     * @param  addResolution  whether to declare the resolutions. Callers should set this argument to {@code false} if they intend
     *                        to provide the resolution themselves, or if grid axes are not in the same order than CRS axes.
     * @return whether a "spatial representation info" node has been added.
     */
    public final boolean addSpatialRepresentation(final String description, final GridGeometry grid, final boolean addResolution) {
        final GridType type;
        if (grid == null) {
            if (description == null) {
                return false;
            }
            type = GridType.UNSPECIFIED;
        } else {
            type = grid.isConversionLinear(0, 1) ? GridType.GEORECTIFIED : GridType.GEOREFERENCEABLE;
        }
        addSpatialRepresentation(SpatialRepresentationType.GRID);
        newGridRepresentation(type);
        setGridToCRS(description);
        if (grid != null) {
            setGeoreferencingAvailability(grid.isDefined(GridGeometry.GRID_TO_CRS), false, false);
            CoordinateSystem cs = null;
            if (grid.isDefined(GridGeometry.CRS)) {
                final CoordinateReferenceSystem crs = grid.getCoordinateReferenceSystem();
                cs = crs.getCoordinateSystem();
                addReferenceSystem(crs);
            }
            if (grid.isDefined(GridGeometry.EXTENT)) {
                final GridExtent extent = grid.getExtent();
                final int dimension = extent.getDimension();
                for (int i=0; i<dimension; i++) {
                    final Optional<DimensionNameType> axisType = extent.getAxisType(i);
                    if (axisType.isPresent()) {
                        setAxisName(i, axisType.get());
                    }
                    setAxisSize(i, extent.getSize(i));
                }
            }
            if (addResolution && grid.isDefined(GridGeometry.RESOLUTION)) {
                final double[] resolution = grid.getResolution(false);
                for (int i=0; i<resolution.length; i++) {
                    setAxisResolution(i, resolution[i], (cs != null) ? cs.getAxis(i).getUnit() : null);
                }
            }
        }
        return true;
    }

    /**
     * Adds a linear resolution in metres.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/spatialResolution/distance}</li>
     * </ul>
     *
     * @param  distance  the resolution in metres, or {@code NaN} for no-operation.
     */
    public final void addResolution(final double distance) {
        if (!Double.isNaN(distance)) {
            final DefaultResolution r = new DefaultResolution();
            r.setDistance(shared(distance));
            addIfNotPresent(identification().getSpatialResolutions(), r);
        }
    }

    /**
     * Sets identification of grid data as point or cell.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/spatialRepresentationInfo/cellGeometry}</li>
     * </ul>
     *
     * @param  value   whether the data represent point or area, or {@code null} for no-operation.
     */
    public final void setCellGeometry(final CellGeometry value) {
        if (value != null) {
            gridRepresentation().setCellGeometry(value);
        }
    }

    /**
     * Sets the point in a pixel corresponding to the Earth location of the pixel.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/spatialRepresentationInfo/pointInPixel}</li>
     * </ul>
     *
     * @param  value   whether the data represent point or area, or {@code null} for no-operation.
     */
    public final void setPointInPixel(final PixelOrientation value) {
        if (value != null) {
            final DefaultGridSpatialRepresentation gridRepresentation = gridRepresentation();
            if (gridRepresentation instanceof DefaultGeorectified) {
                ((DefaultGeorectified) gridRepresentation).setPointInPixel(value);
            }
        }
    }

    /**
     * Sets whether parameters for transformation, control/check point(s) or orientation parameters are available.
     * Storage location are:
     *
     * <ul>
     *   <li>If georeferenceable:<ul>
     *     <li>{@code metadata/spatialRepresentationInfo/transformationParameterAvailability}</li>
     *     <li>{@code metadata/spatialRepresentationInfo/controlPointAvailability}</li>
     *     <li>{@code metadata/spatialRepresentationInfo/orientationParameterAvailability}</li>
     *   </ul></li>
     *   <li>If georeferenced:<ul>
     *     <li>{@code metadata/spatialRepresentationInfo/transformationParameterAvailability}</li>
     *     <li>{@code metadata/spatialRepresentationInfo/checkPointAvailability}</li>
     *   </ul></li>
     * </ul>
     *
     * @param  transformationParameterAvailability  indication of whether or not parameters for transformation exists.
     * @param  controlPointAvailability             indication of whether or not control or check point(s) exists.
     * @param  orientationParameterAvailability     indication of whether or not orientation parameters are available.
     */
    public final void setGeoreferencingAvailability(final boolean transformationParameterAvailability,
                                                    final boolean controlPointAvailability,
                                                    final boolean orientationParameterAvailability)
    {
        final DefaultGridSpatialRepresentation gridRepresentation = gridRepresentation();
        gridRepresentation.setTransformationParameterAvailable(transformationParameterAvailability);
        if (gridRepresentation instanceof DefaultGeorectified) {
            ((DefaultGeorectified) gridRepresentation).setCheckPointAvailable(controlPointAvailability);
        } else if (gridRepresentation instanceof DefaultGeoreferenceable) {
            ((DefaultGeoreferenceable) gridRepresentation).setControlPointAvailable(controlPointAvailability);
            ((DefaultGeoreferenceable) gridRepresentation).setOrientationParameterAvailable(orientationParameterAvailability);
        }
    }

    /**
     * Adds information about the geolocation of an image.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/spatialRepresentationInfo/geolocationInformation}</li>
     * </ul>
     *
     * @param  info  the geolocation information to add, or {@code null} if none.
     */
    public final void addGeolocation(final GeolocationInformation info) {
        if (info != null) {
            final DefaultGridSpatialRepresentation gridRepresentation = gridRepresentation();
            if (gridRepresentation instanceof DefaultGeoreferenceable) {
                addIfNotPresent(((DefaultGeoreferenceable) gridRepresentation).getGeolocationInformation(), info);
            }
        }
    }

    /**
     * Adds <cite>check points</cite> (if georectified) or <cite>ground control points</cite> (if georeferenceable).
     * Ground control points (GCP) are large marked targets on the ground. GCP should not be used for storing the
     * localization grid (e.g. "model tie points" in a GeoTIFF file).
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/spatialRepresentationInfo/checkPoint/geographicCoordinates} if georectified</li>
     *   <li>{@code metadata/spatialRepresentationInfo/geolocationInformation/gcp/geographicCoordinates} if georeferenceable</li>
     * </ul>
     *
     * @param  geographicCoordinates  the geographic or map position of the control point, in either two or three dimensions.
     * @param  accuracyReport         the accuracy of a ground control point, or {@code null} if none.
     *                                Ignored if {@code geographicCoordinates} is null.
     */
    public final void addControlPoints(final DirectPosition geographicCoordinates, final Element accuracyReport) {
        if (geographicCoordinates != null) {
            final DefaultGridSpatialRepresentation gridRepresentation = gridRepresentation();
            final Collection<GCP> points;
            if (gridRepresentation instanceof DefaultGeorectified) {
                points = ((DefaultGeorectified) gridRepresentation).getCheckPoints();
            } else if (gridRepresentation instanceof DefaultGeoreferenceable) {
                points = groundControlPoints().getGCPs();
            } else {
                return;
            }
            final DefaultGCP gcp = new DefaultGCP();
            gcp.setGeographicCoordinates(geographicCoordinates);
            if (accuracyReport != null) {
                addIfNotPresent(gcp.getAccuracyReports(), accuracyReport);
            }
            addIfNotPresent(points, gcp);
        }
    }

    /**
     * Sets a general description of the transformation from grid coordinates to "real world" coordinates.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/spatialRepresentationInfo/transformationDimensionDescription}</li>
     * </ul>
     *
     * @param  value  a general description of the "grid to CRS" transformation, or {@code null} for no-operation.
     */
    public final void setGridToCRS(final CharSequence value) {
        final InternationalString i18n = trim(value);
        if (i18n != null) {
            final DefaultGridSpatialRepresentation gridRepresentation = gridRepresentation();
            if (gridRepresentation instanceof DefaultGeorectified) {
                ((DefaultGeorectified) gridRepresentation).setTransformationDimensionDescription(i18n);
            }
        }
    }

    /**
     * Returns the axis at the given dimension index. All previous dimensions are created if needed.
     *
     * @param  index  index of the desired dimension.
     * @return dimension at the given index.
     */
    private DefaultDimension axis(final int index) {
        final List<Dimension> axes = gridRepresentation().getAxisDimensionProperties();
        for (int i=axes.size(); i <= index; i++) {
            axes.add(new DefaultDimension());
        }
        return (DefaultDimension) axes.get(index);
    }

    /**
     * Sets the number of cells along the given dimension.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/spatialRepresentationInfo/axisDimensionProperties/dimensionName}</li>
     * </ul>
     *
     * @param  dimension  the axis dimension.
     * @param  name       the name to set for the given dimension.
     */
    public final void setAxisName(final int dimension, final DimensionNameType name) {
        axis(dimension).setDimensionName(name);
    }

    /**
     * Sets the number of cells along the given dimension.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/spatialRepresentationInfo/axisDimensionProperties/dimensionSize}</li>
     * </ul>
     *
     * @param  dimension  the axis dimension.
     * @param  length     number of cell values along the given dimension.
     */
    public final void setAxisSize(final int dimension, final long length) {
        if (length >= 0) {
            axis(dimension).setDimensionSize(shared(length > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) length));
        }
    }

    /**
     * Sets the degree of detail in the given dimension.
     * This method does nothing if the given resolution if NaN or infinite.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/spatialRepresentationInfo/axisDimensionProperties/resolution}</li>
     * </ul>
     *
     * @param  dimension   the axis dimension.
     * @param  resolution  the degree of detail in the grid dataset, or NaN for no-operation.
     * @param  unit        the resolution unit, of {@code null} if unknown.
     */
    public final void setAxisResolution(final int dimension, double resolution, final Unit<?> unit) {
        if (Double.isFinite(resolution)) {
            /*
             * Value should be a Quantity<?>. Since GeoAPI does not yet allow that,
             * we convert to metres for now. Future version should store the value
             * as-is with its unit of measurement (TODO).
             */
            if (Units.isLinear(unit)) {
                resolution = unit.asType(Length.class).getConverterTo(Units.METRE).convert(resolution);
            }
            axis(dimension).setResolution(shared(resolution));
        }
    }

    /**
     * Adds type of information represented in the cell.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/contentType}</li>
     * </ul>
     *
     * @param  type  type of information represented in the cell, or {@code null} for no-operation.
     */
    public final void addContentType(final CoverageContentType type) {
        if (type != null) {
            attributeGroup().getContentTypes().add(type);
        }
    }

    /**
     * Sets the sequence identifier, sample value ranges, transfer function and units of measurement
     * from the given sample dimension. This method dispatch its work to other methods in this class.
     * Before to set any value, this method starts a new band by calling {@link #newSampleDimension()}.
     * Storage locations are:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/sequenceIdentifier}</li>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/minValue}</li>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/maxValue}</li>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/scale}</li>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/offset}</li>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/transferFunctionType}</li>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/unit}</li>
     * </ul>
     *
     * @param  band  the sample dimension to describe in metadata, or {@code null} if none.
     */
    public final void addNewBand(final SampleDimension band) {
        if (band != null) {
            newSampleDimension();
            final GenericName g = band.getName();
            if (g != null) {
                final MemberName name;
                if (g instanceof MemberName) {
                    name = (MemberName) g;
                } else {
                    name = Names.createMemberName(null, null, g.tip().toString(), String.class);
                }
                setBandIdentifier(name);
            }
            band.getSampleRange().ifPresent((range) -> {
                addMinimumSampleValue(range.getMinDouble(true));
                addMaximumSampleValue(range.getMaxDouble(true));
            });
            band.getTransferFunctionFormula().ifPresent((tr) -> {
                setTransferFunction(tr.getScale(), tr.getOffset());
                sampleDimension().setTransferFunctionType(tr.getType());
            });
            band.getUnits().ifPresent((unit) -> setSampleUnits(unit));
        }
    }

    /**
     * Sets the name or number that uniquely identifies instances of bands of wavelengths on which a sensor operates.
     * If a coverage contains more than one band, additional bands can be created by calling
     * {@link #newSampleDimension()} before to call this method.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/sequenceIdentifier}</li>
     * </ul>
     *
     * @param  sequenceIdentifier  the band name or number, or {@code null} for no-operation.
     */
    public final void setBandIdentifier(final MemberName sequenceIdentifier) {
        if (sequenceIdentifier != null) {
            sampleDimension().setSequenceIdentifier(sequenceIdentifier);
        }
    }

    /**
     * Sets the number that uniquely identifies instances of bands of wavelengths on which a sensor operates.
     * This is a convenience method for {@link #setBandIdentifier(MemberName)} when the band is specified only
     * by a number.
     *
     * @param  sequenceIdentifier  the band number, or 0 or negative if none.
     */
    public final void setBandIdentifier(final int sequenceIdentifier) {
        if (sequenceIdentifier > 0) {
            setBandIdentifier(Names.createMemberName(null, null, sequenceIdentifier));
        }
    }

    /**
     * Adds an identifier for the current band.
     * These identifiers can be used to provide names for the attribute from a standard set of names.
     * If a coverage contains more than one band, additional bands can be created by calling
     * {@link #newSampleDimension()} before to call this method.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/name}</li>
     * </ul>
     *
     * @param  authority  identifies which controlled list of name is used, or {@code null} if none.
     * @param  name       the band name, or {@code null} for no-operation.
     */
    public final void addBandName(final CharSequence authority, String name) {
        if (name != null && !(name = name.trim()).isEmpty()) {
            addIfNotPresent(sampleDimension().getNames(), sharedIdentifier(authority, name));
        }
    }

    /**
     * Adds a description of the current band.
     * If a coverage contains more than one band, additional bands can be created by calling
     * {@link #newSampleDimension()} before to call this method.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/description}</li>
     * </ul>
     *
     * @param  description  the band description, or {@code null} for no-operation.
     */
    public final void addBandDescription(final CharSequence description) {
        final InternationalString i18n = trim(description);
        if (i18n != null) {
            final DefaultSampleDimension sampleDimension = sampleDimension();
            sampleDimension.setDescription(append(sampleDimension.getDescription(), i18n));
        }
    }

    /**
     * Adds a description of a particular sample value.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/rangeElementDescription}</li>
     * </ul>
     *
     * <div class="note"><b>Note:</b>
     * ISO 19115 range elements are approximately equivalent to
     * {@code org.apache.sis.coverage.Category} in the {@code sis-coverage} module.</div>
     *
     * @param  name        designation associated with a set of range elements, or {@code null} if none.
     * @param  definition  description of a set of specific range elements, or {@code null} if none.
     */
    public void addSampleValueDescription(final CharSequence name, final CharSequence definition) {
        final InternationalString i18n = trim(name);
        final InternationalString def  = trim(definition);
        if (i18n != null && def != null) {
            final DefaultRangeElementDescription element = new DefaultRangeElementDescription();
            element.setName(i18n);
            element.setDefinition(def);
            addIfNotPresent(coverageDescription().getRangeElementDescriptions(), element);
        }
    }

    /**
     * Adds a minimal value for the current sample dimension. If a minimal value was already defined, then
     * the new value will be set only if it is smaller than the existing one. {@code NaN} values are ignored.
     * If a coverage contains more than one band, additional bands can be created by calling
     * {@link #newSampleDimension()} before to call this method.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/minValue}</li>
     * </ul>
     *
     * @param value  the minimal value to add to the existing range of sample values, or {@code NaN} for no-operation.
     */
    public final void addMinimumSampleValue(final double value) {
        if (!Double.isNaN(value)) {
            final DefaultSampleDimension sampleDimension = sampleDimension();
            final Double current = sampleDimension.getMinValue();
            if (current == null || value < current) {
                sampleDimension.setMinValue(shared(value));
            }
        }
    }

    /**
     * Adds a maximal value for the current sample dimension. If a maximal value was already defined, then
     * the new value will be set only if it is greater than the existing one. {@code NaN} values are ignored.
     * If a coverage contains more than one band, additional bands can be created by calling
     * {@link #newSampleDimension()} before to call this method.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/maxValue}</li>
     * </ul>
     *
     * @param value  the maximal value to add to the existing range of sample values, or {@code NaN} for no-operation.
     */
    public final void addMaximumSampleValue(final double value) {
        if (!Double.isNaN(value)) {
            final DefaultSampleDimension sampleDimension = sampleDimension();
            final Double current = sampleDimension.getMaxValue();
            if (current == null || value > current) {
                sampleDimension.setMaxValue(shared(value));
            }
        }
    }

    /**
     * Sets the units of data in the current band.
     * If a coverage contains more than one band, additional bands can be created by calling
     * {@link #newSampleDimension()} before to call this method.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/unit}</li>
     * </ul>
     *
     * @param  unit  units of measurement of sample values.
     */
    public final void setSampleUnits(final Unit<?> unit) {
        if (unit != null) {
            sampleDimension().setUnits(unit);
        }
    }

    /**
     * Sets the scale factor and offset which have been applied to the cell value.
     * The transfer function type is declared {@linkplain TransferFunctionType#LINEAR linear}
     * If a coverage contains more than one band, additional bands can be created by calling
     * {@link #newSampleDimension()} before to call this method.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/scale}</li>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/offset}</li>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/transferFunctionType}</li>
     * </ul>
     *
     * @param scale   the scale factor which has been applied to the cell value.
     * @param offset  the physical value corresponding to a cell value of zero.
     */
    public final void setTransferFunction(final double scale, final double offset) {
        if (!Double.isNaN(scale) || !Double.isNaN(offset)) {
            final DefaultSampleDimension sd = sampleDimension();
            if (!Double.isNaN(scale))  sd.setScaleFactor(scale);
            if (!Double.isNaN(offset)) sd.setOffset(offset);
            sd.setTransferFunctionType(TransferFunctionType.LINEAR);
        }
    }

    /**
     * Sets the maximum number of significant bits in the uncompressed representation for the value in current band.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/attributeGroup/attribute/bitsPerValue}</li>
     * </ul>
     *
     * @param  bits  the new maximum number of significant bits.
     * @throws IllegalArgumentException if the given value is zero or negative.
     */
    public final void setBitPerSample(final int bits) {
        sampleDimension().setBitsPerValue(bits);
    }

    /**
     * Sets an identifier for the level of processing that has been applied to the coverage.
     * For image descriptions, this is the image distributor's code that identifies the level
     * of radiometric and geometric processing that has been applied.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/processingLevelCode}</li>
     * </ul>
     *
     * Note that another storage location exists at {@code metadata/identificationInfo/processingLevel}
     * but is currently not used.
     *
     * @param  authority        identifies which controlled list of code is used, or {@code null} if none.
     * @param  processingLevel  identifier for the level of processing that has been applied to the resource,
     *                          or {@code null} for no-operation.
     */
    public final void setProcessingLevelCode(final CharSequence authority, String processingLevel) {
        if (processingLevel != null) {
            processingLevel = processingLevel.trim();
            if (!processingLevel.isEmpty()) {
                coverageDescription().setProcessingLevelCode(sharedIdentifier(authority, processingLevel));
            }
        }
    }

    /**
     * Sets the area of the dataset obscured by clouds, expressed as a percentage of the spatial extent.
     * This method does nothing if the given value is {@link Double#NaN}.
     *
     * <p>This method is available only if {@link #newCoverage(boolean)} has been invoked
     * with the {@code electromagnetic} parameter set to {@code true}. Storage location is:</p>
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/cloudCoverPercentage}</li>
     * </ul>
     *
     * @param  value  the new cloud percentage, or {@code NaN} for no-operation.
     * @throws IllegalArgumentException if the given value is out of range.
     */
    public final void setCloudCoverPercentage(final double value) {
        if (!Double.isNaN(value)) {
            ((DefaultImageDescription) coverageDescription()).setCloudCoverPercentage(shared(value));
        }
    }

    /**
     * Sets the illumination azimuth measured in degrees clockwise from true north at the time the image is taken.
     * For images from a scanning device, refer to the centre pixel of the image.
     * This method does nothing if the given value is {@link Double#NaN}.
     *
     * <p>This method is available only if {@link #newCoverage(boolean)} has been invoked
     * with the {@code electromagnetic} parameter set to {@code true}. Storage location is:</p>
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/illuminationAzimuthAngle}</li>
     * </ul>
     *
     * @param  value  the new illumination azimuth angle, or {@code NaN} for no-operation.
     * @throws IllegalArgumentException if the given value is out of range.
     */
    public final void setIlluminationAzimuthAngle(final double value) {
        if (!Double.isNaN(value)) {
            ((DefaultImageDescription) coverageDescription()).setIlluminationAzimuthAngle(shared(value));
        }
    }

    /**
     * Sets the illumination elevation measured in degrees clockwise from the target plane
     * at intersection of the optical line of sight with the Earth's surface.
     * For images from a canning device, refer to the centre pixel of the image.
     * This method does nothing if the given value is {@link Double#NaN}.
     *
     * <p>This method is available only if {@link #newCoverage(boolean)} has been invoked
     * with the {@code electromagnetic} parameter set to {@code true}. Storage location is:</p>
     *
     * <ul>
     *   <li>{@code metadata/contentInfo/illuminationElevationAngle}</li>
     * </ul>
     *
     * @param  value  the new illumination azimuth angle, or {@code NaN} for no-operation.
     * @throws IllegalArgumentException if the given value is out of range.
     */
    public final void setIlluminationElevationAngle(final double value) {
        if (!Double.isNaN(value)) {
            ((DefaultImageDescription) coverageDescription()).setIlluminationElevationAngle(shared(value));
        }
    }

    /**
     * Adds a platform on which instrument are installed. If a platform was already defined
     * with a different identifier, then a new platform instance will be created.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/acquisitionInformation/platform/identifier}</li>
     * </ul>
     *
     * @param  authority   identifiers the authority that define platform codes, or {@code null} if none.
     * @param  identifier  identifier of the platform to add, or {@code null} for no-operation.
     */
    public final void addPlatform(final CharSequence authority, String identifier) {
        if (identifier != null && !(identifier = identifier.trim()).isEmpty()) {
            if (platform != null) {
                final Identifier current = platform.getIdentifier();
                if (current != null) {
                    if (identifier.equals(current.getCode())) {
                        return;
                    }
                    acquisition().getPlatforms().add(platform);
                    platform = null;
                }
            }
            platform().setIdentifier(sharedIdentifier(authority, identifier));
        }
    }

    /**
     * Adds an instrument or sensor on the platform.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/acquisitionInformation/platform/instrument/identifier}</li>
     * </ul>
     *
     * @param  authority   identifiers the authority that define instrument codes, or {@code null} if none.
     * @param  identifier  identifier of the sensor to add, or {@code null} for no-operation.
     */
    public final void addInstrument(final CharSequence authority, String identifier) {
        if (identifier != null && !(identifier = identifier.trim()).isEmpty()) {
            final DefaultInstrument instrument = new DefaultInstrument();
            instrument.setIdentifier(sharedIdentifier(authority, identifier));
            addIfNotPresent(platform().getInstruments(), instrument);
        }
    }

    /**
     * Adds an event that describe the time at which data were acquired.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/acquisitionInformation/operation/significantEvent/time}</li>
     * </ul>
     *
     * @param  time  the acquisition time, or {@code null} for no-operation.
     *
     * @see #addTemporalExtent(Date, Date)
     */
    public final void addAcquisitionTime(final Date time) {
        if (time != null) {
            final DefaultEvent event = new DefaultEvent();
            event.setContext(Context.ACQUISITION);
            event.setTime(time);
            final DefaultOperation op = new DefaultOperation();
            op.setSignificantEvents(Collections.singleton(event));
            op.setType(OperationType.REAL);
            op.setStatus(Progress.COMPLETED);
            addIfNotPresent(acquisition().getOperations(), op);
        }
    }

    /**
     * Adds the identifier of the operation used to acquire the dataset.
     * Examples: "GHRSST", "NOAA CDR", "NASA EOS", "JPSS", "GOES-R".
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/acquisitionInformation/operation/identifier}</li>
     * </ul>
     *
     * @param  program     identification of the mission, or {@code null} if none.
     * @param  identifier  unique identification of the operation, or {@code null} for no-operation.
     */
    public final void addAcquisitionOperation(final CharSequence program, String identifier) {
        if (identifier != null && !(identifier = identifier.trim()).isEmpty()) {
            final DefaultOperation r = new DefaultOperation();
            r.setIdentifier(sharedIdentifier(program, identifier));
            addIfNotPresent(acquisition().getOperations(), r);
        }
    }

    /**
     * Adds the identifier of the requirement to be satisfied by data acquisition.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/acquisitionInformation/acquisitionRequirement/identifier}</li>
     * </ul>
     *
     * @param  authority   specifies the authority that define requirement codes, or {@code null} if none.
     * @param  identifier  unique name or code for the requirement, or {@code null} for no-operation.
     */
    public final void addAcquisitionRequirement(final CharSequence authority, String identifier) {
        if (identifier != null && !(identifier = identifier.trim()).isEmpty()) {
            final DefaultRequirement r = new DefaultRequirement();
            r.setIdentifier(sharedIdentifier(authority, identifier));
            addIfNotPresent(acquisition().getAcquisitionRequirements(), r);
        }
    }

    /**
     * Adds a general explanation of the data producer's knowledge about the lineage of a dataset.
     * If a statement already exists, the new one will be appended after a new line.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/resourceLineage/statement}</li>
     * </ul>
     *
     * @param statement  explanation of the data producer's knowledge about the lineage, or {@code null} for no-operation.
     *
     * @see #addProcessDescription(CharSequence)
     */
    public final void addLineage(final CharSequence statement) {
        final InternationalString i18n = trim(statement);
        if (i18n != null) {
            final DefaultLineage lineage = lineage();
            lineage.setStatement(append(lineage.getStatement(), i18n));
        }
    }

    /**
     * Adds information about a source of data used for producing the resource.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/resourceLineage/source/description}</li>
     *   <li>{@code metadata/resourceLineage/source/scope/level}</li>
     *   <li>{@code metadata/resourceLineage/source/scope/levelDescription/features}</li>
     * </ul>
     *
     * <div class="note"><b>Example:</b>
     * if a Landsat image uses the "GTOPO30" digital elevation model, then it can declare the source
     * with "GTOPO30" description, {@link ScopeCode#MODEL} and feature "Digital Elevation Model".</div>
     *
     * @param  description  a detailed description of the level of the source data, or {@code null} if none.
     * @param  level        hierarchical level of the source (e.g. model), or {@code null} if unspecified.
     * @param  feature      more detailed name for {@code level}, or {@code null} if none.
     *
     * @see #addProcessing(CharSequence, String)
     * @see #addProcessDescription(CharSequence)
     */
    public final void addSource(final CharSequence description, final ScopeCode level, final CharSequence feature) {
        final InternationalString i18n = trim(description);
        if (i18n != null) {
            final DefaultSource source = new DefaultSource(description);
            if (level != null || feature != null) {
                DefaultScope scope = new DefaultScope(level);
                if (feature != null) {
                    final DefaultScopeDescription sd = new DefaultScopeDescription();
                    sd.getFeatures().add(new org.apache.sis.metadata.iso.maintenance.LegacyFeatureType(feature));
                    scope.getLevelDescription().add(sd);
                }
            }
            addIfNotPresent(lineage().getSources(), source);
        }
    }

    /**
     * Adds information about a source of data used for producing the resource.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/resourceLineage/source/scope/level}</li>
     *   <li>{@code metadata/resourceLineage/source/scope/extent}</li>
     *   <li>{@code metadata/resourceLineage/source/scope/levelDescription/*}</li>
     *   <li>{@code metadata/resourceLineage/source/citation}</li>
     *   <li>{@code metadata/resourceLineage/source/sourceReferenceSystem}</li>
     *   <li>{@code metadata/resourceLineage/source/sourceSpatialResolution}</li>
     * </ul>
     *
     * <div class="note"><b>Example:</b>
     * if a {@code FeatureSet} is the aggregation of two other {@code FeatureSet} resources,
     * then this method can be invoked twice with the metadata of each source {@code FeatureSet}.
     * If the aggregated data are features, then {@code level} should be {@link ScopeCode#FEATURE}.</div>
     *
     * @param  metadata  the metadata of the source, or {@code null} if none.
     * @param  level     hierarchical level of the source (e.g. feature). Should not be null.
     * @param  features  names of dataset, features or attributes used in the source.
     */
    public final void addSource(final Metadata metadata, final ScopeCode level, final CharSequence... features) {
        if (metadata != null) {
            final DefaultSource source = new DefaultSource();
            final DefaultScope scope = new DefaultScope(level);
            source.setSourceReferenceSystem(CollectionsExt.first(metadata.getReferenceSystemInfo()));
            for (final Identification id : metadata.getIdentificationInfo()) {
                source.setSourceCitation(id.getCitation());
                if (id instanceof AbstractIdentification) {
                    final AbstractIdentification aid = (AbstractIdentification) id;
                    source.setSourceSpatialResolution(CollectionsExt.first(aid.getSpatialResolutions()));
                    scope.setExtents(aid.getExtents());
                }
                if (features != null && features.length != 0) {
                    /*
                     * Note: the same ScopeDescription may be shared by many Source instances
                     * in the common case where many sources contain features of the same type.
                     */
                    final DefaultScopeDescription sd = new DefaultScopeDescription();
                    sd.setLevelDescription(level, new LinkedHashSet<>(Arrays.asList(features)));
                    scope.getLevelDescription().add(shared(DefaultScopeDescription.class, sd));
                }
                source.setScope(scope.isEmpty() ? null : scope);
                if (!source.isEmpty()) {
                    addIfNotPresent(lineage().getSources(), source);
                    break;
                }
            }
        }
    }

    /**
     * Adds information about the procedure, process and algorithm applied in a process step.
     * If a processing was already defined with a different identifier, then a new processing
     * instance will be created. Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/resourceLineage/processStep/processingInformation/identifier}</li>
     * </ul>
     *
     * @param  authority   identifies the authority that defines processing code, or {@code null} if none.
     * @param  identifier  processing package that produced the data, or {@code null} for no-operation.
     *
     * @see #addSoftwareReference(CharSequence)
     * @see #addHostComputer(CharSequence)
     * @see #addProcessDescription(CharSequence)
     * @see #addSource(CharSequence, ScopeCode, CharSequence)
     */
    public final void addProcessing(final CharSequence authority, String identifier) {
        if (identifier != null && !(identifier = identifier.trim()).isEmpty()) {
            if (processing != null) {
                final Identifier current = processing.getIdentifier();
                if (current != null) {
                    if (identifier.equals(current.getCode())) {
                        return;
                    }
                    processStep().setProcessingInformation(processing);
                    addIfNotPresent(lineage().getProcessSteps(), processStep);
                    processing  = null;
                    processStep = null;
                }
            }
            processing().setIdentifier(sharedIdentifier(authority, identifier));
        }
    }

    /**
     * Adds a reference to document describing processing software.
     * This is added to the processing identified by last call to {@link #addProcessing(CharSequence, String)}.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/resourceLineage/processStep/processingInformation/softwareReference/title}</li>
     * </ul>
     *
     * @param  title  title of the document that describe the software, or {@code null} for no-operation.
     *
     * @see #addProcessing(CharSequence, String)
     * @see #addSource(CharSequence, ScopeCode, CharSequence)
     */
    public final void addSoftwareReference(final CharSequence title) {
        final InternationalString i18n = trim(title);
        if (i18n != null) {
            addIfNotPresent(processing().getSoftwareReferences(), sharedCitation(i18n));
        }
    }

    /**
     * Adds information about the computer and/or operating system in use at the processing time.
     * This is added to the processing identified by last call to {@link #addProcessing(CharSequence, String)}.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/resourceLineage/processStep/processingInformation/procedureDescription}</li>
     * </ul>
     *
     * @param  platform  name of the system on which the processing has been executed, or {@code null} for no-operation.
     *
     * @see #addProcessing(CharSequence, String)
     * @see #addSource(CharSequence, ScopeCode, CharSequence)
     */
    public final void addHostComputer(final CharSequence platform) {
        InternationalString i18n = trim(platform);
        if (i18n != null) {
            i18n = Resources.formatInternational(Resources.Keys.ProcessingExecutedOn_1, i18n);
            final DefaultProcessing p = processing();
            p.setProcedureDescription(append(p.getProcedureDescription(), i18n));
        }
    }

    /**
     * Adds additional details about the process step.
     * If a description already exists, the new one will be added on a new line.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/resourceLineage/processStep/description}</li>
     * </ul>
     *
     * @param  description  additional details about the process step, or {@code null} for no-operation.
     *
     * @see #addProcessing(CharSequence, String)
     * @see #addSource(CharSequence, ScopeCode, CharSequence)
     * @see #addLineage(CharSequence)
     */
    public final void addProcessDescription(final CharSequence description) {
        final InternationalString i18n = trim(description);
        if (i18n != null) {
            final DefaultProcessStep ps = processStep();
            ps.setDescription(append(ps.getDescription(), i18n));
        }
    }

    /**
     * Adds a name to the resource format. Note that this method does not add a new format,
     * but only an alternative name to current format. Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/resourceFormat/formatSpecificationCitation/alternateTitle}</li>
     * </ul>
     *
     * If this method is used together with {@link #setFormat(String)},
     * then {@code setFormat} should be invoked <strong>before</strong> this method.
     *
     * @param value  the format name, or {@code null} for no-operation.
     *
     * @see #setFormat(String)
     * @see #setFormatEdition(CharSequence)
     * @see #addCompression(CharSequence)
     */
    public final void addFormatName(final CharSequence value) {
        final InternationalString i18n = trim(value);
        if (i18n != null) {
            final DefaultFormat format = format();
            DefaultCitation citation = DefaultCitation.castOrCopy(format.getFormatSpecificationCitation());
            if (citation == null) {
                citation = new DefaultCitation(i18n);
            } else {
                addIfNotPresent(citation.getAlternateTitles(), i18n);
            }
            format.setFormatSpecificationCitation(citation);
        }
    }

    /**
     * Sets a version number for the resource format. Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/resourceFormat/formatSpecificationCitation/edition}</li>
     * </ul>
     *
     * If this method is used together with {@link #setFormat(String)},
     * then {@code setFormat} should be invoked <strong>before</strong> this method.
     *
     * @param value  the format edition, or {@code null} for no-operation.
     *
     * @see #setFormat(String)
     * @see #addFormatName(CharSequence)
     */
    public final void setFormatEdition(final CharSequence value) {
        final InternationalString i18n = trim(value);
        if (i18n != null) {
            final DefaultFormat format = format();
            DefaultCitation citation = DefaultCitation.castOrCopy(format.getFormatSpecificationCitation());
            if (citation == null) {
                citation = new DefaultCitation();
                format.setFormatSpecificationCitation(citation);
            }
            citation.setEdition(i18n);
        }
    }

    /**
     * Adds a compression name.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/identificationInfo/resourceFormat/fileDecompressionTechnique}</li>
     * </ul>
     *
     * If this method is used together with {@link #setFormat(String)},
     * then {@code setFormat} should be invoked <strong>before</strong> this method.
     *
     * @param value  the compression name, or {@code null} for no-operation.
     *
     * @see #setFormat(String)
     * @see #addFormatName(CharSequence)
     */
    public final void addCompression(final CharSequence value) {
        final InternationalString i18n = trim(value);
        if (i18n != null) {
            final DefaultFormat format = format();
            format.setFileDecompressionTechnique(append(format.getFileDecompressionTechnique(), i18n));
        }
    }

    /**
     * Adds a URL to a more complete description of the metadata.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/metadataLinkage/linkage}
     *     with {@code function} set to {@code OnLineFunction.COMPLETE_METADATA}</li>
     * </ul>
     *
     * @param  link  URL to a more complete description of the metadata, or {@code null}.
     */
    public final void addCompleteMetadata(final URI link) {
        if (link != null) {
            final DefaultOnlineResource ln = new DefaultOnlineResource(link);
            ln.setFunction(OnLineFunction.valueOf("COMPLETE_METADATA"));
            ln.setProtocol(link.getScheme());
            addIfNotPresent(metadata().getMetadataLinkages(), ln);
        }
    }

    /**
     * Sets the metadata standards to ISO 19115-1, and optionally to ISO 19115-2 too.
     * Those metadata citations are added only if the metadata object is otherwise non-empty.
     * Storage location is:
     *
     * <ul>
     *   <li>{@code metadata/metadataStandards}</li>
     * </ul>
     *
     * @param  part2  whether to set ISO 19115-2 in addition to ISO 19115-1.
     */
    public final void setISOStandards(final boolean part2) {
        standardISO = part2 ? (byte) 2 : (byte) 1;
    }

    /**
     * Returns the metadata (optionally as an unmodifiable object), or {@code null} if none.
     * If {@code freeze} is {@code true}, then the returned metadata instance can not be modified.
     *
     * @param  freeze  {@code true} if this method should set the returned metadata to
     *                 {@link DefaultMetadata.State#FINAL}, or {@code false} for leaving the metadata editable.
     * @return the metadata, or {@code null} if none.
     */
    public final DefaultMetadata build(final boolean freeze) {
        newIdentification();
        newGridRepresentation(GridType.UNSPECIFIED);
        newFeatureTypes();
        newCoverage(false);
        newAcquisition();
        newDistribution();
        newLineage();
        final DefaultMetadata md = metadata;
        metadata = null;
        if (md != null) {
            if (standardISO != 0) {
                List<Citation> c = Citations.ISO_19115;
                if (standardISO == 1) {
                    c = Collections.singletonList(c.get(0));
                }
                md.setMetadataStandards(c);
            }
            if (freeze) {
                md.transitionTo(DefaultMetadata.State.FINAL);
            }
        }
        return md;
    }

    /**
     * Returns a shared instance of the given object if it already exists.
     * If the given object is new, then it is added to the cache and returned.
     *
     * <p>It is caller's responsibility to ensure that the type given in argument
     * does not conflict with one of the type documented in {@link #sharedValues}.</p>
     */
    private <T> T shared(final Class<T> type, final T value) {
        final T existing = type.cast(sharedValues.putIfAbsent(value, value));
        return (existing != null) ? existing : value;
    }

    /**
     * Returns a shared instance of the given value.
     * This is a helper method for callers who want to set themselves some additional
     * metadata values on the instance returned by {@link #build(boolean)}.
     *
     * @param   value  a double value.
     * @return  the given value, but as an existing instance if possible.
     */
    protected final Double shared(final double value) {
        final Double n = Numerics.valueOf(value);
        final Object existing = sharedValues.putIfAbsent(n, n);
        return (existing != null) ? (Double) existing : n;
    }

    /**
     * Returns a shared instance of the given value.
     * This is a helper method for callers who want to set themselves some additional
     * metadata values on the instance returned by {@link #build(boolean)}.
     *
     * @param   value  an integer value.
     * @return  the same value, but as an existing instance if possible.
     */
    protected final Integer shared(final int value) {
        final Integer n = value;
        final Object existing = sharedValues.putIfAbsent(n, n);
        return (existing != null) ? (Integer) existing : n;
    }
}
