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

import static java.lang.StrictMath.toRadians;
import org.opengis.util.FactoryException;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.referencing.datum.Ellipsoid;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.MathTransformFactory;
import org.opengis.referencing.operation.NoninvertibleTransformException;
import org.opengis.referencing.operation.TransformException;
import org.apache.sis.referencing.CommonCRS;
import org.apache.sis.referencing.internal.shared.Formulas;
import org.apache.sis.parameter.Parameters;
import org.apache.sis.referencing.operation.matrix.Matrix4;
import org.apache.sis.referencing.operation.transform.CoordinateDomain;
import org.apache.sis.referencing.operation.transform.EllipsoidToCentricTransform;
import org.apache.sis.referencing.operation.transform.LinearTransform;
import org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory;

// Test dependencies
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import org.apache.sis.referencing.operation.transform.MathTransformTestCase;

// Specific to the main branch:
import static org.apache.sis.referencing.internal.shared.CoordinateOperations.builder;


/**
 * Tests {@link GeocentricTranslation} and {@link GeocentricTranslation3D}.
 *
 * @author  Martin Desruisseaux (Geomatys)
 */
public final class GeocentricTranslationTest extends MathTransformTestCase {
    /**
     * Creates a new test case.
     */
    public GeocentricTranslationTest() {
    }

    /**
     * Geocentric translation parameters for transforming a point in the North Sea from WGS84 to ED50.
     * They are the parameters to use for transformation of the point given by {@link #samplePoint(int)}.
     */
    public static final double TX = 84.87, TY = 96.49, TZ = 116.95;

    /**
     * Returns the sample point for a step in the example given by the EPSG guidance note.
     *
     * <blockquote><b>Source:</b>
     * §2.4.3.5 <cite>Three-parameter geocentric translations</cite> and
     * §2.4.4.2 <cite>Abridged Molodensky transformation</cite> in
     * IOGP Publication 373-7-2 – Geomatics Guidance Note number 7, part 2 – April 2015
     * </blockquote>
     *
     * The example transforms a point in the North Sea from WGS84 to ED50.
     * The transform can be decomposed in 4 steps:
     * <pre>
     *   <b>Step 1:</b> Source point in WGS84:  53°48'33.820"N,  02°07'46.380"E,  73.00 metres.
     *   <b>Step 2:</b> Geocentric (X,Y,Z):      3771793.968,  140253.342,  5124304.349 metres.
     *   <b>Step 3:</b> (X,Y,Z) after shift:         +84.87,      +96.49,      +116.95  metres
     *   <b>Step 4:</b> Target point in ED50:   53°48'36.565"N,  02'07"51.477"E,  28.02 metres.
     * </pre>
     *
     * @param  step  the step as a value from 1 to 5 inclusive.
     * @return the sample point at the given step.
     */
    public static double[] samplePoint(final int step) {
        switch (step) {
            case 1: return new double[] {
                         2 + ( 7 + 46.380/60)/60,   // λ: Longitude
                        53 + (48 + 33.820/60)/60,   // φ: Latitude
                        73.00                       // h: Height
                    };
            case 2: return new double[] {
                        3771793.968,                // X: Toward prime meridian
                         140253.342,                // Y: Toward 90° east
                        5124304.349                 // Z: Toward north pole
                    };
            case 3: return new double[] {
                        3771878.84,                 // X: Toward prime meridian
                         140349.83,                 // Y: Toward 90° east
                        5124421.30                  // Z: Toward north pole
                    };
            case 4: return new double[] {           // Result according geocentric translation
                         2 + ( 7 + 51.477/60)/60,   // λ: Longitude
                        53 + (48 + 36.565/60)/60,   // φ: Latitude
                        28.02                       // h: Height
                    };
            case 5: return new double[] {           // Result according abridged Molodensky
                         2 + ( 7 + 51.477/60)/60,   // λ: Longitude
                        53 + (48 + 36.563/60)/60,   // φ: Latitude
                        28.091                      // h: Height
                    };
            default: throw new AssertionError(step);
        }
    }

    /**
     * Returns the sample point precision for the given step.
     *
     * @param  step  the step as a value from 1 to 4 inclusive.
     * @return the sample point precision for the given step.
     */
    public static double precision(final int step) {
        switch (step) {
            case 1:
            case 4:
            case 5: return 0.001 / 60 / 60 / 2;     // Half the precision of (λ,φ) values given by EPSG
            case 2: return 0.001 / 2;               // Half the precision for (X,Y,Z) values given by EPSG
            case 3: return 0.01 / 2;                // Half the precision for (X,Y,Z) values given by EPSG
            default: throw new AssertionError(step);
        }
    }

    /**
     * Creates a transformation for the given method.
     *
     * @param method  the method to test.
     */
    private void create(final GeocentricAffine method) throws FactoryException {
        final ParameterValueGroup values = method.getParameters().createValue();
        setTranslation(values);
        if (method instanceof GeocentricAffineBetweenGeographic) {
            setEllipsoids(values, CommonCRS.WGS84.ellipsoid(), CommonCRS.ED50.ellipsoid());
        }
        transform = method.createMathTransform(DefaultMathTransformFactory.provider(), values);
    }

    /**
     * Creates a datum shift operation without using the provider. This method is equivalent to the
     * <q>Geocentric translations (geog3D domain)</q> method (EPSG:1035), but the transform
     * steps are created without the use of any {@link ParameterValueGroup}. This way to create the
     * datum shift is provided for better separation of aspects being tested.
     *
     * @param  factory  the factory to use for creating the transforms.
     * @param  source   the source ellipsoid.
     * @param  target   the target ellipsoid.
     * @param  tX       geocentric translation on the X axis.
     * @param  tY       geocentric translation on the Y axis.
     * @param  tZ       geocentric translation on the Z axis.
     * @return transform performing the datum shift.
     * @throws FactoryException if an error occurred while creating the transform.
     * @throws NoninvertibleTransformException if an error occurred while creating the transform.
     *
     * @see #testGeographicDomain()
     */
    public static MathTransform createDatumShiftForGeographic3D(
            final MathTransformFactory factory,
            final Ellipsoid source, final Ellipsoid target,
            final double tX, final double tY, final double tZ)
            throws FactoryException, NoninvertibleTransformException
    {
        final var translation = new Matrix4();
        translation.m03 = tX;
        translation.m13 = tY;
        translation.m23 = tZ;
        final MathTransform step1 = EllipsoidToCentricTransform.createGeodeticConversion(factory, source, true);
        final MathTransform step3 = EllipsoidToCentricTransform.createGeodeticConversion(factory, target, true).inverse();
        final MathTransform step2 = factory.createAffineTransform(translation);
        return factory.createConcatenatedTransform(step1,
               factory.createConcatenatedTransform(step2, step3));
    }

    /**
     * Creates a "Geographic 2D to 3D → Geocentric → Affine → Geographic → Geographic 3D to 2D" chain
     * using EPSG or OGC standard operation methods and parameters. This is used for integration tests.
     *
     * @param  factory  the math transform factory to use for creating and concatenating the transform.
     * @return the chain of transforms.
     * @throws FactoryException if an error occurred while creating a transform.
     */
    public static MathTransform createDatumShiftForGeographic2D(final MathTransformFactory factory) throws FactoryException {
        final Parameters values = Parameters.castOrWrap(builder(factory, "Geocentric translations (geog2D domain)").parameters());
        setTranslation(values);
        setEllipsoids(values, CommonCRS.WGS84.ellipsoid(), CommonCRS.ED50.ellipsoid());
        final MathTransform gt = new GeocentricTranslation().createMathTransform(factory, values);
        assertFalse(gt.isIdentity());
        assertEquals(3, gt.getSourceDimensions());
        assertEquals(3, gt.getTargetDimensions());
        assertInstanceOf(LinearTransform.class, gt);
        return Geographic3Dto2DTest.createDatumShiftForGeographic2D(factory, gt, values);
    }

    /**
     * Sets the translation parameters in the given parameter value group.
     */
    private static void setTranslation(final ParameterValueGroup values) {
        values.parameter("X-axis translation").setValue(TX);
        values.parameter("Y-axis translation").setValue(TY);
        values.parameter("Z-axis translation").setValue(TZ);
    }

    /**
     * Sets the source and target ellipsoid axes in the given parameter value group.
     */
    static void setEllipsoids(final ParameterValueGroup values, final Ellipsoid source, final Ellipsoid target) {
        values.parameter("src_semi_major").setValue(source.getSemiMajorAxis());
        values.parameter("src_semi_minor").setValue(source.getSemiMinorAxis());
        values.parameter("tgt_semi_major").setValue(target.getSemiMajorAxis());
        values.parameter("tgt_semi_minor").setValue(target.getSemiMinorAxis());
    }

    /**
     * Tests transformation of the sample point from WGS84 to ED50.
     *
     * @param  sourceStep  the {@link #samplePoint(int)} to use as the source coordinate.
     * @param  targetStep  the {@link #samplePoint(int)} expected as a result of the transformation.
     */
    private void datumShift(final int sourceStep, final int targetStep) throws TransformException {
        tolerance = precision(targetStep);
        tolerance = Formulas.LINEAR_TOLERANCE;                 // Other SIS branches use a stricter threshold.
        verifyTransform(samplePoint(sourceStep), samplePoint(targetStep));
        validate();
    }

    /**
     * Tests <q>Geocentric translations (geocentric domain)</q> (EPSG:1031).
     *
     * @throws FactoryException if an error occurred while creating the transform.
     * @throws TransformException if transformation of a point failed.
     */
    @Test
    public void testGeocentricDomain() throws FactoryException, TransformException {
        create(new GeocentricTranslation());
        assertInstanceOf(LinearTransform.class, transform);
        derivativeDeltas = new double[] {100, 100, 100};                // In metres
        datumShift(2, 3);
    }

    /**
     * Tests <q>Geocentric translations (geog3D domain)</q> (EPSG:1035).
     *
     * @throws FactoryException if an error occurred while creating the transform.
     * @throws TransformException if transformation of a point failed.
     */
    @Test
    public void testGeographicDomain() throws FactoryException, TransformException {
        create(new GeocentricTranslation3D());
        assertFalse(transform instanceof LinearTransform);
        final double delta = toRadians(100.0 / 60) / 1852;      // Approximately 100 metres
        derivativeDeltas = new double[] {delta, delta, 100};    // (Δλ, Δφ, Δh)
        zTolerance = Formulas.LINEAR_TOLERANCE / 2;             // Half the precision of h value given by EPSG
        zDimension = new int[] {2};                             // Dimension of h where to apply zTolerance
        tolerance  = Formulas.LINEAR_TOLERANCE;                 // Other SIS branches use a stricter threshold.
        datumShift(1, 4);
    }

    /**
     * Tests conversion of random points.
     *
     * @throws FactoryException if an error occurred while creating the transform.
     * @throws TransformException if transformation of a point failed.
     */
    @Test
    public void testRandomPoints() throws FactoryException, TransformException {
        testGeographicDomain();                     // For creating the transform.
        tolerance = Formulas.LINEAR_TOLERANCE;
//      toleranceModifier = ToleranceModifier.GEOGRAPHIC;
        verifyInDomain(CoordinateDomain.GEOGRAPHIC, 831342815);
    }

    /**
     * Tests Well Known Text formatting of a two-dimensional transform.
     * The main point of this test is to verify that Geographic 2D/3D conversions have been
     * inserted before and after the transform, and that Geographic/Geocentric conversions
     * have been replaced by Bursa-Wolf parameters for formatting purpose.
     *
     * @throws FactoryException if an error occurred while creating the transform.
     */
    @Test
    public void testWKT2D() throws FactoryException {
        create(new GeocentricTranslation2D());
        verifyWKT2D();
    }

    /**
     * Verifies the WKT formatting of current transform.
     */
    private void verifyWKT2D() {
        assertWktEquals("CONCAT_MT[\n" +
                        "  INVERSE_MT[PARAM_MT[“Geographic3D to 2D conversion”]],\n" +
                        "  PARAM_MT[“Ellipsoid_To_Geocentric”,\n" +
                        "    PARAMETER[“semi_major”, 6378137.0],\n" +
                        "    PARAMETER[“semi_minor”, 6356752.314245179]],\n" +
                        "  PARAM_MT[“Geocentric translations (geocentric domain)”,\n" +
                        "    PARAMETER[“dx”, 84.87],\n" +
                        "    PARAMETER[“dy”, 96.49],\n" +
                        "    PARAMETER[“dz”, 116.95]],\n" +
                        "  PARAM_MT[“Geocentric_To_Ellipsoid”,\n" +
                        "    PARAMETER[“semi_major”, 6378388.0],\n" +
                        "    PARAMETER[“semi_minor”, 6356911.9461279465]],\n" +
                        "  PARAM_MT[“Geographic3D to 2D conversion”]]");
    }

    /**
     * Tests Well Known Text formatting.
     * The main point of this test is to verify that the affine transform between the two
     * Geographic/Geocentric conversions have been replaced by Bursa-Wolf parameters for
     * formatting purpose.
     *
     * @throws FactoryException if an error occurred while creating the transform.
     */
    @Test
    public void testWKT3D() throws FactoryException {
        create(new GeocentricTranslation3D());
        assertWktEquals("CONCAT_MT[\n" +
                        "  PARAM_MT[“Ellipsoid_To_Geocentric”,\n" +
                        "    PARAMETER[“semi_major”, 6378137.0],\n" +
                        "    PARAMETER[“semi_minor”, 6356752.314245179]],\n" +
                        "  PARAM_MT[“Geocentric translations (geocentric domain)”,\n" +
                        "    PARAMETER[“dx”, 84.87],\n" +
                        "    PARAMETER[“dy”, 96.49],\n" +
                        "    PARAMETER[“dz”, 116.95]],\n" +
                        "  PARAM_MT[“Geocentric_To_Ellipsoid”,\n" +
                        "    PARAMETER[“semi_major”, 6378388.0],\n" +
                        "    PARAMETER[“semi_minor”, 6356911.9461279465]]]");
        /*
         * The above WKT what was we show to users. But what we have in memory is different:
         * affines before, after and between the two Geographic/Geocentric conversions.
         */
        assertInternalWktEquals(
                "Concat_MT[\n" +
                "  Param_MT[“Affine”,\n" +
                "    Parameter[“num_row”, 4],\n" +
                "    Parameter[“num_col”, 4],\n" +
                "    Parameter[“elt_0_0”, 0.017453292519943295],\n" +
                "    Parameter[“elt_1_1”, 0.017453292519943295],\n" +
                "    Parameter[“elt_2_2”, 1.567855942887398E-7]],\n" +
                "  Param_MT[“Ellipsoid (radians domain) to centric”,\n" +
                "    Parameter[“eccentricity”, 0.08181919084262244],\n" +
                "    Parameter[“csType”, “CARTESIAN”],\n" +
                "    Parameter[“dim”, 3]],\n" +
                "  Param_MT[“Affine”,\n" +
                "    Parameter[“num_row”, 4],\n" +
                "    Parameter[“num_col”, 4],\n" +
                "    Parameter[“elt_0_0”, 0.9999606483644456],\n" +
                "    Parameter[“elt_0_3”, 1.3305869758942228E-5],\n" +
                "    Parameter[“elt_1_1”, 0.9999606483644456],\n" +
                "    Parameter[“elt_1_3”, 1.512764667185502E-5],\n" +
                "    Parameter[“elt_2_2”, 0.9999606483644456],\n" +
                "    Parameter[“elt_2_3”, 1.8335353697517302E-5]],\n" +
                "  Param_MT[“Centric to ellipsoid (radians domain)”,\n" +
                "    Parameter[“eccentricity”, 0.08199188997902906],\n" +
                "    Parameter[“csType”, “CARTESIAN”],\n" +
                "    Parameter[“dim”, 3]],\n" +
                "  Param_MT[“Affine”,\n" +
                "    Parameter[“num_row”, 4],\n" +
                "    Parameter[“num_col”, 4],\n" +
                "    Parameter[“elt_0_0”, 57.29577951308232],\n" +
                "    Parameter[“elt_1_1”, 57.29577951308232],\n" +
                "    Parameter[“elt_2_2”, 6378388.0]]]");
    }

    /**
     * Tests "Geographic 2D to 3D → Geocentric → Affine → Geographic → Geographic 3D to 2D" chain
     * created from a {@link MathTransformFactory}. Because this test involves a lot of steps,
     * this is more an integration test than a unit test: a failure here may not be easy to debug.
     *
     * @throws FactoryException if an error occurred while creating the transform.
     */
    @Test
    public void testIntegration() throws FactoryException {
        transform = createDatumShiftForGeographic2D(DefaultMathTransformFactory.provider());
        verifyWKT2D();
    }
}
