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

import java.util.List;
import javax.xml.bind.annotation.XmlTransient;
import org.opengis.util.FactoryException;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.parameter.ParameterDescriptor;
import org.opengis.parameter.ParameterDescriptorGroup;
import org.opengis.referencing.cs.CartesianCS;
import org.opengis.referencing.cs.EllipsoidalCS;
import org.opengis.referencing.cs.CoordinateSystem;
import org.opengis.referencing.operation.Matrix;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.MathTransformFactory;
import org.apache.sis.internal.referencing.Formulas;
import org.apache.sis.internal.referencing.WKTUtilities;
import org.apache.sis.internal.referencing.WKTKeywords;
import org.apache.sis.internal.system.Loggers;
import org.apache.sis.io.wkt.FormattableObject;
import org.apache.sis.io.wkt.Formatter;
import org.apache.sis.measure.Units;
import org.apache.sis.parameter.Parameters;
import org.apache.sis.parameter.Parameterized;
import org.apache.sis.parameter.ParameterBuilder;
import org.apache.sis.metadata.iso.citation.Citations;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.referencing.datum.BursaWolfParameters;
import org.apache.sis.referencing.operation.transform.MathTransforms;
import org.apache.sis.referencing.operation.transform.LinearTransform;
import org.apache.sis.util.logging.Logging;


/**
 * The base class of operation methods performing a translation, rotation and/or scale in geocentric coordinates.
 * Those methods may or may not include a Geographic/Geocentric conversion before the operation in geocentric domain,
 * depending on whether or not implementations extend the {@link GeocentricAffineBetweenGeographic} subclass.
 *
 * <div class="note"><b>Note on class name:</b>
 * the {@code GeocentricAffine} class name is chosen as a generalization of {@link GeocentricTranslation}.
 * "Geocentric translations" is an operation name defined by EPSG.</div>
 *
 * @author  Martin Desruisseaux (IRD, Geomatys)
 * @version 0.8
 * @since   0.7
 * @module
 */
@XmlTransient
public abstract class GeocentricAffine extends GeodeticOperation {
    /**
     * Serial number for inter-operability with different versions.
     */
    private static final long serialVersionUID = 8291967302538661639L;

    /**
     * The tolerance factor for comparing the {@link BursaWolfParameters} values.
     * We use a tolerance of 1E-6 ({@value Formulas#LINEAR_TOLERANCE} / 10000) based on the knowledge
     * that the translation terms are in metres and the rotation terms have the same order of magnitude.
     * Actually we could use a value of zero, but we add a small tolerance for rounding errors.
     */
    private static final double BURSAWOLF_TOLERANCE = Formulas.LINEAR_TOLERANCE / 10000;

    /**
     * The operation parameter descriptor for the <cite>X-axis translation</cite>
     * ({@linkplain BursaWolfParameters#tX tX}) parameter value. Valid values range
     * from negative to positive infinity. Units are {@linkplain Units#METRE metres}.
     */
    public static final ParameterDescriptor<Double> TX;

    /**
     * The operation parameter descriptor for the <cite>Y-axis translation</cite>
     * ({@linkplain BursaWolfParameters#tY tY}) parameter value. Valid values range
     * from negative to positive infinity. Units are {@linkplain Units#METRE metres}.
     */
    public static final ParameterDescriptor<Double> TY;

    /**
     * The operation parameter descriptor for the <cite>Z-axis translation</cite>
     * ({@linkplain BursaWolfParameters#tZ tZ}) parameter value. Valid values range
     * from negative to positive infinity. Units are {@linkplain Units#METRE metres}.
     */
    public static final ParameterDescriptor<Double> TZ;

    /**
     * The operation parameter descriptor for the <cite>X-axis rotation</cite>
     * ({@linkplain BursaWolfParameters#rX rX}) parameter value.
     * Units are {@linkplain Units#ARC_SECOND arc-seconds}.
     */
    static final ParameterDescriptor<Double> RX;

    /**
     * The operation parameter descriptor for the <cite>Y-axis rotation</cite>
     * ({@linkplain BursaWolfParameters#rY rY}) parameter value.
     * Units are {@linkplain Units#ARC_SECOND arc-seconds}.
     */
    static final ParameterDescriptor<Double> RY;

    /**
     * The operation parameter descriptor for the <cite>Z-axis rotation</cite>
     * ({@linkplain BursaWolfParameters#rZ rZ}) parameter value.
     * Units are {@linkplain Units#ARC_SECOND arc-seconds}.
     */
    static final ParameterDescriptor<Double> RZ;

    /**
     * The operation parameter descriptor for the <cite>Scale difference</cite>
     * ({@linkplain BursaWolfParameters#dS dS}) parameter value.
     * Valid values range from negative to positive infinity.
     * Units are {@linkplain Units#PPM parts per million}.
     */
    static final ParameterDescriptor<Double> DS;
    static {
        final ParameterBuilder builder = builder();
        TX = createShift(builder.addIdentifier("8605").addName("X-axis translation").addName(Citations.OGC, "dx"));
        TY = createShift(builder.addIdentifier("8606").addName("Y-axis translation").addName(Citations.OGC, "dy"));
        TZ = createShift(builder.addIdentifier("8607").addName("Z-axis translation").addName(Citations.OGC, "dz"));
        RX = createRotation(builder.addIdentifier("8608"), "X-axis rotation", "ex");
        RY = createRotation(builder.addIdentifier("8609"), "Y-axis rotation", "ey");
        RZ = createRotation(builder.addIdentifier("8610"), "Z-axis rotation", "ez");
        DS = builder.addIdentifier("8611").addName("Scale difference").addName(Citations.OGC, "ppm").create(0, Units.PPM);
    }

    /**
     * Convenience method for building the rotation parameters.
     */
    private static ParameterDescriptor<Double> createRotation(final ParameterBuilder builder, final String name, final String alias) {
        return builder.addName(name).addName(Citations.OGC, alias).createBounded(-180*60*60, 180*60*60, 0, Units.ARC_SECOND);
    }

    /**
     * Return value for {@link #getType()}.
     */
    static final int TRANSLATION=1, SEVEN_PARAM=2, FRAME_ROTATION=3, OTHER=0;

    /**
     * Constructs a provider with the specified parameters.
     *
     * @param sourceDimensions  number of dimensions in the source CRS of this operation method.
     * @param targetDimensions  number of dimensions in the target CRS of this operation method.
     * @param parameters        description of parameters expected by this operation.
     * @param redimensioned     providers for all combinations between 2D and 3D cases, or {@code null}.
     */
    GeocentricAffine(int sourceDimensions, int targetDimensions, ParameterDescriptorGroup parameters, GeodeticOperation[] redimensioned) {
        super(sourceDimensions, targetDimensions, parameters, redimensioned);
    }

    /**
     * Returns the operation sub-type as one of {@link #TRANSLATION}, {@link #SEVEN_PARAM},
     * {@link #FRAME_ROTATION} or {@link #OTHER} constants.
     */
    abstract int getType();

    /**
     * Creates a math transform from the specified group of parameter values.
     * The default implementation creates an affine transform, but some subclasses
     * will wrap that affine operation into Geographic/Geocentric conversions.
     *
     * @param  factory  the factory to use for creating concatenated transforms.
     * @param  values   the group of parameter values.
     * @return the created math transform.
     * @throws FactoryException if a transform can not be created.
     */
    @Override
    @SuppressWarnings("fallthrough")
    public MathTransform createMathTransform(final MathTransformFactory factory, final ParameterValueGroup values)
            throws FactoryException
    {
        final BursaWolfParameters parameters = new BursaWolfParameters(null, null);
        final Parameters pv = Parameters.castOrWrap(values);
        boolean reverseRotation = false;
        switch (getType()) {
            default:             throw new AssertionError();
            case FRAME_ROTATION: reverseRotation = true;                    // Fall through
            case SEVEN_PARAM:    parameters.rX = pv.doubleValue(RX);
                                 parameters.rY = pv.doubleValue(RY);
                                 parameters.rZ = pv.doubleValue(RZ);
                                 parameters.dS = pv.doubleValue(DS);
            case TRANSLATION:    parameters.tX = pv.doubleValue(TX);        // Fall through
                                 parameters.tY = pv.doubleValue(TY);
                                 parameters.tZ = pv.doubleValue(TZ);
        }
        if (reverseRotation) {
            parameters.reverseRotation();
        }
        return MathTransforms.linear(parameters.getPositionVectorTransformation(null));
    }

    /**
     * Creates parameter values for a Molodensky, Geocentric Translation or Position Vector transformation.
     *
     * @param  descriptor     the {@code PARAMETERS} constant of the subclass describing the operation to create.
     * @param  parameters     Bursa-Wolf parameters from which to get the values.
     * @param  isTranslation  {@code true} if the operation contains only translation terms.
     * @return the operation parameters with their values initialized.
     */
    private static Parameters createParameters(final ParameterDescriptorGroup descriptor,
            final BursaWolfParameters parameters, final boolean isTranslation)
    {
        final Parameters values = Parameters.castOrWrap(descriptor.createValue());
        values.getOrCreate(TX).setValue(parameters.tX);
        values.getOrCreate(TY).setValue(parameters.tY);
        values.getOrCreate(TZ).setValue(parameters.tZ);
        if (!isTranslation) {
            values.getOrCreate(RX).setValue(parameters.rX);
            values.getOrCreate(RY).setValue(parameters.rY);
            values.getOrCreate(RZ).setValue(parameters.rZ);
            values.getOrCreate(DS).setValue(parameters.dS);
        }
        return values;
    }

    /**
     * Returns the parameters for creating a datum shift operation.
     * The operation method will be one of the {@code GeocentricAffine} subclasses,
     * unless the specified {@code method} argument is {@link DatumShiftMethod#NONE}.
     * If no single operation method can be used, then this method returns {@code null}.
     *
     * <p>This method does <strong>not</strong> change the coordinate system type.
     * The source and target coordinate systems can be both {@code EllipsoidalCS} or both {@code CartesianCS}.
     * Any other type or mix of types (e.g. a {@code EllipsoidalCS} source and {@code CartesianCS} target)
     * will cause this method to return {@code null}. In such case, it is caller's responsibility to apply
     * the datum shift itself in Cartesian geocentric coordinates.</p>
     *
     * @param  sourceCS    the source coordinate system. Only the type and number of dimensions is checked.
     * @param  targetCS    the target coordinate system. Only the type and number of dimensions is checked.
     * @param  datumShift  the datum shift as a matrix, or {@code null} if there is no datum shift information.
     * @param  method      the preferred datum shift method. Note that {@code createParameters(…)} may overwrite.
     * @return the parameter values, or {@code null} if no single operation method can be found.
     */
    public static ParameterValueGroup createParameters(final CoordinateSystem sourceCS,
            final CoordinateSystem targetCS, final Matrix datumShift, DatumShiftMethod method)
    {
        final boolean isEllipsoidal = (sourceCS instanceof EllipsoidalCS);
        if (!(isEllipsoidal ? (targetCS instanceof EllipsoidalCS)
                            : (targetCS instanceof CartesianCS && sourceCS instanceof CartesianCS)))
        {
            return null;        // Coordinate systems are not two EllipsoidalCS or two CartesianCS.
        }
        @SuppressWarnings("null")
        int dimension  = sourceCS.getDimension();
        if (dimension != targetCS.getDimension()) {
            dimension  = 4;     // Any value greater than 3 means "mismatched dimensions" for this method.
        }
        if (method == DatumShiftMethod.NONE) {
            if (dimension <= 3) {
                return Affine.identity(dimension);
            } else if (isEllipsoidal) {
                final ParameterDescriptorGroup descriptor;
                switch (sourceCS.getDimension()) {
                    case 2: descriptor = Geographic2Dto3D.PARAMETERS; break;
                    case 3: descriptor = Geographic3Dto2D.PARAMETERS; break;
                    default: return null;
                }
                return descriptor.createValue();
            } else {
                return null;
            }
        }
        /*
         * Try to convert the matrix into (tX, tY, tZ, rX, rY, rZ, dS) parameters.
         * The matrix may not be convertible, in which case we will let the caller
         * uses the matrix directly in Cartesian geocentric coordinates.
         */
        final BursaWolfParameters parameters = new BursaWolfParameters(null, null);
        if (datumShift != null) try {
            parameters.setPositionVectorTransformation(datumShift, BURSAWOLF_TOLERANCE);
        } catch (IllegalArgumentException e) {
            log(Loggers.COORDINATE_OPERATION, "createParameters", e);
            return null;
        } else {
            /*
             * If there is no datum shift parameters (not to be confused with identity), then those parameters
             * are assumed unknown. Using the most accurate methods would give a false impression of accuracy,
             * so we use the fastest method instead. Since all parameter values are zero, Apache SIS should use
             * the AbridgedMolodenskyTransform2D optimization.
             */
            method = DatumShiftMethod.ABRIDGED_MOLODENSKY;
        }
        final boolean isTranslation = parameters.isTranslation();
        final ParameterDescriptorGroup descriptor;
        /*
         * Following "if" blocks are ordered from most accurate to less accurate datum shift method
         * supported by GeocentricAffine subclasses (except NONE which has already been handled).
         * Special cases:
         *
         *   - If the datum shift is applied between geocentric CRS, then the Molodensky approximations do not apply
         *     as they are designed for transformations between geographic CRS only. User preference is then ignored.
         *
         *   - Molodensky methods are approximations for datum shifts having only translation terms in their Bursa-Wolf
         *     parameters. If there is also a scale or rotation terms, then we can not use Molodensky methods. The user
         *     preference is then ignored.
         */
        if (!isEllipsoidal) {
            method = DatumShiftMethod.GEOCENTRIC_DOMAIN;
            descriptor = isTranslation ? GeocentricTranslation.PARAMETERS
                                       : PositionVector7Param .PARAMETERS;
        } else if (!isTranslation) {
            method = DatumShiftMethod.GEOCENTRIC_DOMAIN;
            descriptor = (dimension >= 3) ? PositionVector7Param3D.PARAMETERS
                                          : PositionVector7Param2D.PARAMETERS;
        } else switch (method) {
            case GEOCENTRIC_DOMAIN: {
                descriptor = (dimension >= 3) ? GeocentricTranslation3D.PARAMETERS
                                              : GeocentricTranslation2D.PARAMETERS;
                break;
            }
            case MOLODENSKY: {
                descriptor = Molodensky.PARAMETERS;
                break;
            }
            case ABRIDGED_MOLODENSKY: {
                descriptor = AbridgedMolodensky.PARAMETERS;
                break;
            }
            default: throw new AssertionError(method);
        }
        /*
         * Following lines will set all Bursa-Wolf parameter values (scale, translation
         * and rotation terms). In the particular case of Molodensky method, we have an
         * additional parameter for the number of source and target dimensions (2 or 3).
         */
        final Parameters values = createParameters(descriptor, parameters, isTranslation);
        switch (method) {
            case MOLODENSKY:
            case ABRIDGED_MOLODENSKY: {
                if (dimension <= 3) {
                    values.getOrCreate(Molodensky.DIMENSION).setValue(dimension);
                }
                break;
            }
        }
        return values;
    }

    /**
     * Given a transformation chain, conditionally replaces the affine transform elements by an alternative object
     * showing the Bursa-Wolf parameters. The replacement is applied if and only if the affine transform is a scale,
     * translation or rotation in the geocentric domain.
     *
     * <p>This method is invoked only by {@code ConcatenatedTransform.getPseudoSteps()} for the need of WKT formatting.
     * The purpose of this method is very similar to the purpose of {@code AbstractMathTransform.beforeFormat(List, int,
     * boolean)} except that we need to perform the {@code forDatumShift(…)} work only after {@code beforeFormat(…)}
     * finished its work for all {@code ContextualParameters}, including the {@code EllipsoidToCentricTransform}'s one.</p>
     *
     * @param  transforms  the full chain of concatenated transforms.
     */
    public static void asDatumShift(final List<Object> transforms) {
        for (int i=transforms.size() - 2; --i >= 0;) {
            if (isOperation(GeographicToGeocentric.NAME, transforms.get(i)) &&
                isOperation(GeocentricToGeographic.NAME, transforms.get(i+2)))
            {
                final Object step = transforms.get(i+1);
                if (step instanceof LinearTransform) {
                    final BursaWolfParameters parameters = new BursaWolfParameters(null, null);
                    try {
                        parameters.setPositionVectorTransformation(((LinearTransform) step).getMatrix(), BURSAWOLF_TOLERANCE);
                    } catch (IllegalArgumentException e) {
                        /*
                         * Should not occur, except sometime on inverse transform of relatively complex datum shifts
                         * (more than just translation terms). We can fallback on formatting the full matrix.
                         */
                        log(Loggers.WKT, "asDatumShift", e);
                        continue;
                    }
                    final boolean isTranslation = parameters.isTranslation();
                    final Parameters values = createParameters(isTranslation ? GeocentricTranslation.PARAMETERS
                                            : PositionVector7Param.PARAMETERS, parameters, isTranslation);
                    transforms.set(i+1, new FormattableObject() {
                        @Override protected String formatTo(final Formatter formatter) {
                            WKTUtilities.appendParamMT(values, formatter);
                            return WKTKeywords.Param_MT;
                        }
                    });
                }
            }
        }
    }

    /**
     * Returns {@code true} if the given object is an operation of the given name.
     */
    private static boolean isOperation(final String expected, final Object actual) {
        return (actual instanceof Parameterized) &&
               IdentifiedObjects.isHeuristicMatchForName(((Parameterized) actual).getParameterDescriptors(), expected);
    }

    /**
     * Logs a warning about a failure to compute the Bursa-Wolf parameters.
     */
    private static void log(final String logger, final String method, final Exception e) {
        Logging.recoverableException(Logging.getLogger(logger), GeocentricAffine.class, method, e);
    }
}
