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

import java.util.Arrays;
import java.util.Set;
import java.util.Map;
import java.util.LinkedHashMap;
import java.util.Objects;
import org.opengis.util.GenericName;
import org.opengis.util.FactoryException;
import org.opengis.geometry.Envelope;
import org.opengis.parameter.ParameterDescriptorGroup;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.operation.CoordinateOperation;
import org.opengis.referencing.operation.TransformException;
import org.apache.sis.internal.feature.AttributeConvention;
import org.apache.sis.internal.feature.FeatureUtilities;
import org.apache.sis.internal.feature.Geometries;
import org.apache.sis.internal.util.CollectionsExt;
import org.apache.sis.geometry.Envelopes;
import org.apache.sis.geometry.GeneralEnvelope;
import org.apache.sis.referencing.CRS;
import org.apache.sis.util.resources.Errors;


/**
 * An operation computing the envelope that encompass all geometries found in a list of attributes.
 * Geometries can be in different coordinate reference systems; they will be transformed to the first
 * non-null CRS in the following choices:
 *
 * <ol>
 *   <li>the CRS specified at construction time,</li>
 *   <li>the CRS of the default geometry, or</li>
 *   <li>the CRS of the first non-empty geometry.</li>
 * </ol>
 *
 * <div class="section">Limitations</div>
 * If a geometry contains other geometries, this operation queries only the envelope of the root geometry.
 * It is the root geometry responsibility to take in account the envelope of all its children.
 *
 * <p>This operation is read-only. Calls to {@code Attribute.setValue(Envelope)} will result in an
 * {@link IllegalStateException} to be thrown.</p>
 *
 * @author  Johann Sorel (Geomatys)
 * @author  Martin Desruisseaux (Geomatys)
 * @version 0.7
 * @since   0.7
 * @module
 */
final class EnvelopeOperation extends AbstractOperation {
    /**
     * For cross-version compatibility.
     */
    private static final long serialVersionUID = 6250548001562807671L;

    /**
     * The parameter descriptor for the "Envelope" operation, which does not take any parameter.
     */
    private static final ParameterDescriptorGroup EMPTY_PARAMS = FeatureUtilities.parameters("Envelope");

    /**
     * The names of all properties containing a geometry object.
     */
    private final String[] attributeNames;

    /**
     * The coordinate reference system of the envelope to compute, or {@code null}
     * for using the CRS of the default geometry or the first non-empty geometry.
     */
    final CoordinateReferenceSystem crs;

    /**
     * The coordinate conversions or transformations from the CRS used by the geometries to the CRS requested
     * by the user, or {@code null} if there is no operation to apply.  If non-null, the length of this array
     * shall be equal to the length of the {@link #attributeNames} array and element at index <var>i</var> is
     * the operation from the {@code attributeNames[i]} geometry CRS to the {@link #crs}.
     *
     * <p>This array contains null element when the {@code MathTransform} associated to the coordinate operation
     * is the identity transform.</p>
     */
    private final CoordinateOperation[] attributeToCRS;

    /**
     * The property names as an unmodifiable set, created when first needed.
     */
    private transient Set<String> dependencies;

    /**
     * The type of the result returned by the envelope operation.
     */
    private final DefaultAttributeType<Envelope> resultType;

    /**
     * Creates a new operation computing the envelope of features of the given type.
     *
     * @param identification      the name and other information to be given to this operation.
     * @param crs                 the coordinate reference system of envelopes to computes, or {@code null}.
     * @param geometryAttributes  the operation or attribute type from which to get geometry values.
     */
    EnvelopeOperation(final Map<String,?> identification, CoordinateReferenceSystem crs,
            final AbstractIdentifiedType[] geometryAttributes) throws FactoryException
    {
        super(identification);
        String defaultGeometry = null;
        final String characteristicName = AttributeConvention.CRS_CHARACTERISTIC.toString();
        /*
         * Get all property names without duplicated values. If a property is a link to an attribute,
         * then the key will be the name of the referenced attribute instead than the operation name.
         * The intent is to avoid querying the same geometry twice if the attribute is also specified
         * explicitly in the array of properties.
         *
         * The map values will be the default Coordinate Reference System, or null if none.
         */
        boolean characterizedByCRS = false;
        final Map<String,CoordinateReferenceSystem> names = new LinkedHashMap<>(4);
        for (AbstractIdentifiedType property : geometryAttributes) {
            if (AttributeConvention.isGeometryAttribute(property)) {
                final GenericName name = property.getName();
                final String attributeName = (property instanceof LinkOperation)
                                             ? ((LinkOperation) property).referentName : name.toString();
                final boolean isDefault = AttributeConvention.GEOMETRY_PROPERTY.equals(name);
                if (isDefault) {
                    defaultGeometry = attributeName;
                }
                CoordinateReferenceSystem attributeCRS = null;
                while (property instanceof AbstractOperation) {
                    property = ((AbstractOperation) property).getResult();
                }
                /*
                 * At this point 'property' is an attribute, otherwise isGeometryAttribute(property) would have
                 * returned false. Set 'characterizedByCRS' to true if we find at least one attribute which may
                 * have the "CRS" characteristic. Note that we can not rely on 'attributeCRS' being non-null
                 * because an attribute may be characterized by a CRS without providing default CRS.
                 */
                final DefaultAttributeType<?> at = ((DefaultAttributeType<?>) property).characteristics().get(characteristicName);
                if (at != null && CoordinateReferenceSystem.class.isAssignableFrom(at.getValueClass())) {
                    attributeCRS = (CoordinateReferenceSystem) at.getDefaultValue();              // May still null.
                    if (crs == null && isDefault) {
                        crs = attributeCRS;
                    }
                    characterizedByCRS = true;
                }
                names.putIfAbsent(attributeName, attributeCRS);
            }
        }
        /*
         * Copy the names in an array with the default geometry first. If possible, find the coordinate operations
         * now in order to avoid the potentially costly call to CRS.findOperation(…) for each feature on which this
         * EnvelopeOperation will be applied.
         */
        names.remove(null);                                                                     // Paranoiac safety.
        attributeNames = new String[names.size()];
        attributeToCRS = characterizedByCRS ? new CoordinateOperation[attributeNames.length] : null;
        int n = (defaultGeometry == null) ? 0 : 1;
        for (final Map.Entry<String,CoordinateReferenceSystem> entry : names.entrySet()) {
            final int i;
            final String name = entry.getKey();
            if (name.equals(defaultGeometry)) {
                defaultGeometry = null;
                i = 0;
            } else {
                i = n++;
            }
            attributeNames[i] = name;
            if (characterizedByCRS) {
                final CoordinateReferenceSystem value = entry.getValue();
                if (value != null) {
                    if (crs == null) {
                        crs = value;                                    // Fallback if default geometry has no CRS.
                    }
                    final CoordinateOperation op = CRS.findOperation(value, crs, null);
                    if (!op.getMathTransform().isIdentity()) {
                        attributeToCRS[i] = op;
                    }
                }
            }
        }
        resultType = FeatureOperations.POOL.unique(new DefaultAttributeType<>(
                resultIdentification(identification), Envelope.class, 1, 1, null));
        this.crs = crs;
    }

    /**
     * Returns an empty group of parameters since this operation does not require any parameter.
     *
     * @return empty parameter group.
     */
    @Override
    public ParameterDescriptorGroup getParameters() {
        return EMPTY_PARAMS;
    }

    /**
     * Returns the type of results computed by this operation, which is {@code AttributeType<Envelope>}.
     * The attribute type name depends on the value of {@code "result.*"} properties (if any)
     * given at construction time.
     *
     * @return an {@code AttributeType<Envelope>}.
     */
    @Override
    public AbstractIdentifiedType getResult() {
        return resultType;
    }

    /**
     * Returns the names of feature properties that this operation needs for performing its task.
     */
    @Override
    @SuppressWarnings("ReturnOfCollectionOrArrayField")
    public synchronized Set<String> getDependencies() {
        if (dependencies == null) {
            dependencies = CollectionsExt.immutableSet(true, attributeNames);
        }
        return dependencies;
    }

    /**
     * Returns an attribute whose value is the union of the envelopes of all geometries in the given feature
     * found in properties specified at construction time.
     *
     * @param  feature     the feature on which to execute the operation.
     * @param  parameters  ignored (can be {@code null}).
     * @return the envelope of geometries in feature property values.
     */
    @Override
    public Property apply(AbstractFeature feature, ParameterValueGroup parameters) {
        return new Result(feature);
    }




    /**
     * The attributes that contains the result of union of all envelope extracted from other attributes.
     * Value is calculated each time it is accessed.
     */
    private final class Result extends AbstractAttribute<Envelope> {
        /**
         * For cross-version compatibility.
         */
        private static final long serialVersionUID = 926172863066901618L;

        /**
         * The feature specified to the {@code StringJoinOperation.apply(Feature, ParameterValueGroup)} method.
         */
        private final AbstractFeature feature;

        /**
         * Creates a new attribute for the given feature.
         */
        Result(final AbstractFeature feature) {
            super(resultType);
            this.feature = feature;
        }

        /**
         * Computes an envelope which is the union of envelope of geometry values of all properties
         * specified to the {@link EnvelopeOperation} constructor.
         *
         * @return the union of envelopes of all geometries in the attribute specified to the constructor,
         *         or {@code null} if none.
         */
        @Override
        public Envelope getValue() throws IllegalStateException {
            final String[] attributeNames = EnvelopeOperation.this.attributeNames;
            GeneralEnvelope envelope = null;                                        // Union of all envelopes.
            for (int i=0; i<attributeNames.length; i++) {
                Envelope genv;                                                      // Envelope of a single geometry.
                final String name = attributeNames[i];
                if (attributeToCRS == null) {
                    /*
                     * If there is no CRS characteristic on any of the properties to query, then invoke the
                     * Feature.getPropertyValue(String) method instead than Feature.getProperty(String) in
                     * order to avoid forcing DenseFeature and SparseFeature implementations to wrap the
                     * property values into real property instances. This is an optimization for reducing
                     * the amount of objects to create.
                     */
                    genv = Geometries.getEnvelope(feature.getPropertyValue(name));
                    if (genv == null) continue;
                } else {
                    /*
                     * If there is at least one CRS characteristic to query, then we need the full Property instance.
                     * We do not distinguish which particular property may have a CRS characteristic because SIS 0.7
                     * implementations of DenseFeature and SparseFeature have a "all of nothing" behavior anyway.
                     */
                    final Property property = (Property) feature.getProperty(name);
                    genv = Geometries.getEnvelope(property.getValue());
                    if (genv == null) continue;
                    /*
                     * Get the CRS characteristic if present. Most of the time, 'at' will be null and we will
                     * fallback on the 'attributeToCRS' operations computed at construction time. In the rare
                     * cases where a CRS characteristic is associated to a particular feature, we will let
                     * Envelopes.transform(…) searches a coordinate operation.
                     */
                    final AbstractAttribute<?> at = ((AbstractAttribute<?>) property).characteristics()
                                    .get(AttributeConvention.CRS_CHARACTERISTIC.toString());
                    try {
                        if (at == null) {
                            final CoordinateOperation op = attributeToCRS[i];
                            if (op != null) {                           // Null operation means identity transform.
                                genv = Envelopes.transform(op, genv);
                            }
                        } else {                                                        // Should be a rare case.
                            final Object geomCRS = at.getValue();
                            if (!(geomCRS instanceof CoordinateReferenceSystem)) {
                                throw new IllegalStateException(Errors.format(Errors.Keys.UnspecifiedCRS));
                            }
                            ((GeneralEnvelope) genv).setCoordinateReferenceSystem((CoordinateReferenceSystem) geomCRS);
                            genv = Envelopes.transform(genv, crs);
                        }
                    } catch (TransformException e) {
                        throw new IllegalStateException(Errors.format(Errors.Keys.CanNotTransformEnvelope), e);
                    }
                }
                if (envelope == null) {
                    envelope = GeneralEnvelope.castOrCopy(genv);        // Should always be a cast without copy.
                } else {
                    envelope.add(genv);
                }
            }
            return envelope;
        }

        /**
         * Unconditionally throws an {@link UnsupportedOperationException}.
         */
        @Override
        public void setValue(Envelope value) {
            throw new UnsupportedOperationException(Errors.format(Errors.Keys.UnmodifiableObject_1, AbstractAttribute.class));
        }
    }

    /**
     * Computes a hash-code value for this operation.
     */
    @Override
    public int hashCode() {
        return super.hashCode() + Arrays.hashCode(attributeNames) + Arrays.hashCode(attributeToCRS);
    }

    /**
     * Compares this operation with the given object for equality.
     */
    @Override
    public boolean equals(final Object obj) {
        if (super.equals(obj)) {
            // 'this.result' is compared (indirectly) by the super class.
            final EnvelopeOperation that = (EnvelopeOperation) obj;
            return Arrays.equals(attributeNames, that.attributeNames) &&
                   Arrays.equals(attributeToCRS, that.attributeToCRS) &&
                   Objects.equals(crs, that.crs);
        }
        return false;
    }
}
