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

import java.util.Map;
import java.util.HashMap;
import org.opengis.util.FactoryException;
import org.opengis.parameter.ParameterValue;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.crs.GeodeticCRS;
import org.opengis.referencing.crs.GeographicCRS;
import org.opengis.referencing.crs.TemporalCRS;
import org.opengis.referencing.cs.EllipsoidalCS;
import org.opengis.referencing.operation.Matrix;
import org.opengis.referencing.operation.Conversion;
import org.opengis.referencing.operation.OperationMethod;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.referencing.internal.shared.CoordinateOperations;
import org.apache.sis.referencing.internal.shared.ReferencingUtilities;
import org.apache.sis.referencing.datum.DefaultGeodeticDatum;
import org.apache.sis.referencing.crs.DefaultGeographicCRS;
import org.apache.sis.referencing.operation.matrix.Matrix3;
import org.apache.sis.referencing.operation.matrix.Matrix4;
import org.apache.sis.referencing.operation.matrix.Matrices;
import org.apache.sis.referencing.operation.transform.MathTransforms;

// Test dependencies
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import org.apache.sis.test.TestCase;
import org.apache.sis.referencing.cs.HardCodedCS;
import org.apache.sis.referencing.crs.HardCodedCRS;
import org.apache.sis.referencing.datum.HardCodedDatum;
import org.apache.sis.parameter.DefaultParameterDescriptorTest;
import static org.apache.sis.test.Assertions.assertMessageContains;
import static org.apache.sis.test.Assertions.assertSerializedEquals;

// Specific to the main branch:
import static org.apache.sis.pending.geoapi.referencing.MissingMethods.getDatumEnsemble;
import static org.apache.sis.test.GeoapiAssert.assertMatrixEquals;


/**
 * Tests {@link DefaultConversion}.
 *
 * @author  Martin Desruisseaux (Geomatys)
 */
public final class DefaultConversionTest extends TestCase {
    /**
     * The rotation from a CRS using the Paris prime meridian to a CRS using the Greenwich prime meridian,
     * in degrees. The definitive value is 2.5969213 grads.
     */
    private static final double OFFSET = 2.33722917;

    /**
     * Creates a new test case.
     */
    public DefaultConversionTest() {
    }

    /**
     * Creates a CRS using the same ellipsoid as the "Nouvelle Triangulation Française (Paris)" datum (EPSG:6807),
     * but with the prime meridian optionally set to Greenwich. Such CRS is not in real usage, but this is convenient
     * for testing a conversion consisting of only a longitude rotation, which is a very simple operation easy to test.
     *
     * @param  isSource      {@code true} if creating the source CRS, or {@code false} if creating the target CRS.
     * @param  cs            {@link HardCodedCS#GEODETIC_2D}, {@link HardCodedCS#GEODETIC_φλ} or other compatible coordinate system.
     * @param  useGreenwich  {@code true} for using Greenwich prime meridian, or {@code false} for staying on the Paris one.
     *
     * @see HardCodedCRS#NTF
     */
    private static GeographicCRS createParisCRS(final boolean isSource, final EllipsoidalCS cs, final boolean useGreenwich) {
        DefaultGeodeticDatum datum = HardCodedDatum.NTF;
        if (useGreenwich) {
            datum = new DefaultGeodeticDatum(Map.of(DefaultGeodeticDatum.NAME_KEY, datum.getName()),
                                             datum.getEllipsoid(), HardCodedDatum.GREENWICH);
        }
        return new DefaultGeographicCRS(
                Map.of(GeographicCRS.NAME_KEY, isSource ? HardCodedCRS.NTF.getName() : "Back to Greenwich"),
                datum, null, cs);
    }

    /**
     * Changes only the coordinate system of the given CRS, which is supposed geographic.
     */
    private static GeographicCRS changeCS(final CoordinateReferenceSystem crs, final EllipsoidalCS cs) {
        final var source = (GeodeticCRS) crs;
        return new DefaultGeographicCRS(
                Map.of(DefaultGeographicCRS.NAME_KEY, crs.getName()),
                source.getDatum(), getDatumEnsemble(source), cs);
    }

    /**
     * Creates a pseudo-conversion performing a longitude rotation between two-dimensional normalized CRS.
     * The source CRS uses the Paris prime meridian and the target CRS "conceptually" uses the Greenwich
     * prime meridian.
     *
     * <p><b>This is not really a valid conversion</b> since, strictly speaking, <cite>Longitude rotations</cite>
     * are coordinate <em>transformations</em> rather than conversions (because they change the datum, since they
     * change the prime meridian). However, we handle them as conversions for testing purpose only, because the
     * longitude rotation is a very simple operation easy to test.</p>
     *
     * @param  useGreenwich  {@code true} for using Greenwich prime meridian in the {@code targetCRS},
     *                       or {@code false} for staying on the Paris one.
     * @return a pseudo-conversion performing a longitude rotation.
     */
    public static DefaultConversion createLongitudeRotation(final boolean useGreenwich) {
        return createLongitudeRotation(HardCodedCRS.NTF_NORMALIZED_AXES,
                createParisCRS(false, HardCodedCS.GEODETIC_2D, useGreenwich), null);
    }

    /**
     * Creates a very simple conversion performing a longitude rotation.
     * The source CRS shall use the Paris prime meridian and the target CRS the Greenwich prime meridian,
     * at least conceptually. See {@link #createLongitudeRotation(boolean)} for an explanation about why
     * this is not really a valid conversion.
     *
     * @param  sourceCRS         a CRS using the Paris prime meridian.
     * @param  targetCRS         a CRS using the Greenwich prime meridian.
     * @param  interpolationCRS  a dummy interpolation CRS, or {@code null} if none.
     */
    private static DefaultConversion createLongitudeRotation(final GeographicCRS sourceCRS,
            final GeographicCRS targetCRS, final TemporalCRS interpolationCRS)
    {
        /*
         * The following code fills the parameter values AND creates itself the MathTransform instance
         * (indirectly, through the matrix). The latter step is normally not our business, since we are
         * supposed to only fill the parameter values and let MathTransformFactory creates the transform
         * from the parameters. But we don't do the normal steps here because this class is a unit test:
         * we want to test DefaultConversion in isolation of MathTransformFactory.
         */
        final int interpDim = ReferencingUtilities.getDimension(interpolationCRS);
        final int sourceDim = sourceCRS.getCoordinateSystem().getDimension();
        final int targetDim = targetCRS.getCoordinateSystem().getDimension();
        final OperationMethod method = DefaultOperationMethodTest.create(
                "Longitude rotation", "9601", "EPSG guidance note #7-2",
                DefaultParameterDescriptorTest.createEPSG("Longitude offset", (short) 8602));
        final ParameterValueGroup pg = method.getParameters().createValue();
        pg.parameter("Longitude offset").setValue(OFFSET);
        final Matrix rotation = Matrices.createDiagonal(
                targetDim + interpDim + 1,                                  // Number of rows.
                sourceDim + interpDim + 1);                                 // Number of columns.
        rotation.setElement(interpDim, interpDim + sourceDim, OFFSET);
        /*
         * In theory we should not need to provide the parameters explicitly to the constructor since
         * we are supposed to be able to find them from the MathTransform. But in this simple test we
         * did not bothered to define a specialized MathTransform class for our case. So we will help
         * a little bit DefaultConversion by telling it the parameters that we used.
         */
        final Map<String, Object> properties = new HashMap<>(4);
        properties.put(DefaultTransformation.NAME_KEY, "Paris to Greenwich");
        properties.put(CoordinateOperations.PARAMETERS_KEY, pg);
        return new DefaultConversion(properties, sourceCRS, targetCRS, interpolationCRS,
                method, MathTransforms.linear(rotation));
    }

    /**
     * Asserts that at least some of the properties of the given {@code op} instance have the expected values
     * for an instance created by {@link #createLongitudeRotation(GeographicCRS, GeographicCRS, TemporalCRS)}.
     */
    @SuppressWarnings("SuspiciousToArrayCall")
    private static void verifyProperties(final DefaultConversion op, final boolean swapSourceAxes) {
        assertEquals("Paris to Greenwich", op.getName().getCode());
        assertEquals("NTF (Paris)",        op.getSourceCRS().getName().getCode());
        assertEquals("Back to Greenwich",  op.getTargetCRS().getName().getCode());
        assertEquals("Longitude rotation", op.getMethod().getName().getCode());
        assertEquals("Longitude rotation", op.getParameterDescriptors().getName().getCode());

        final ParameterValueGroup parameters = op.getParameterValues();
        final ParameterValue<?>[] values = parameters.values().toArray(new ParameterValue<?>[1]);
        assertEquals("Longitude rotation", parameters.getDescriptor().getName().getCode());
        assertEquals("Longitude offset",    values[0].getDescriptor().getName().getCode());
        assertEquals(OFFSET, values[0].doubleValue());
        assertEquals(1, values.length);

        final Matrix3 expected = new Matrix3();
        expected.m02 = OFFSET;
        if (swapSourceAxes) {
            expected.m00 = expected.m11 = 0;
            expected.m01 = expected.m10 = 1;
        }
        assertMatrixEquals(expected, MathTransforms.getMatrix(op.getMathTransform()), STRICT,
                           "Longitude rotation of a two-dimensional CRS");
    }

    /**
     * Tests a simple two-dimensional conversion performing a longitude rotation.
     */
    @Test
    public void testConstruction() {
        /*
         * Test construction of a valid conversion.
         * This conversion use the same datum for the source and target CRS.
         */
        verifyProperties(createLongitudeRotation(false), false);
        /*
         * Test a conversion with a source and target CRS using different datum.
         * This is a violation of conversion definition, but SIS is tolerant to
         * such violation. See DefaultConversion constructor javadoc for discussion.
         */
        verifyProperties(createLongitudeRotation(true), false);
    }

    /**
     * Creates a defining conversion and tests {@link DefaultConversion#specialize DefaultConversion.specialize(…)}.
     * This test includes a swapping of axis order in the <em>source</em> CRS.
     * By contrast, {@link #testSpecialize()} will test swapping axis order in the <em>target</em> CRS.
     *
     * @throws FactoryException if an error occurred while creating the conversion.
     */
    @Test
    public void testDefiningConversion() throws FactoryException {
        final DefaultConversion reference = createLongitudeRotation(true);
        final DefaultConversion definingConversion = new DefaultConversion(
                IdentifiedObjects.getProperties(reference),
                reference.getMethod(),
                reference.getMathTransform(),
                reference.getParameterValues());
        /*
         * By definition, defining conversions have no source and target CRS.
         * This make them different from "normal" conversions.
         */
        assertNull(definingConversion.getSourceCRS());
        assertNull(definingConversion.getTargetCRS());
        assertNotEquals(definingConversion, reference);
        assertNotEquals(reference, definingConversion);
        /*
         * Now create a normal conversion from the defining one,
         * but add a swapping of (latitude, longitude) axes.
         */
        final Conversion completed = definingConversion.specialize(
                changeCS(reference.getSourceCRS(), HardCodedCS.GEODETIC_φλ),
                reference.getTargetCRS(),
                null);

        verifyProperties(assertInstanceOf(DefaultConversion.class, completed), true);
    }

    /**
     * Tests {@link DefaultConversion#specialize DefaultConversion.specialize(…)} with new source and target CRS.
     * This test attempts to swap axis order and change the number of dimensions of the <em>target</em> CRS.
     * By contrast, {@link #testDefiningConversion()} tested swapping axis order in the <em>source</em> CRS.
     *
     * @throws FactoryException if an error occurred while creating the conversion.
     */
    @Test
    public void testSpecialize() throws FactoryException {
        DefaultConversion op = createLongitudeRotation(
                createParisCRS(true,  HardCodedCS.GEODETIC_3D, false),
                createParisCRS(false, HardCodedCS.GEODETIC_3D, true), null);
        assertMatrixEquals(new Matrix4(1, 0, 0, OFFSET,
                                       0, 1, 0, 0,
                                       0, 0, 1, 0,
                                       0, 0, 0, 1),
                MathTransforms.getMatrix(op.getMathTransform()), STRICT,
                "Longitude rotation of a three-dimensional CRS");
        /*
         * When asking for a "specialization" with the same properties,
         * we should get the existing instance since no change is needed.
         */
        assertSame(op, op.specialize(op.getSourceCRS(), op.getTargetCRS(), null));
        /*
         * Reducing the number of dimensions to 2 and swapping (latitude, longitude) axes.
         */
        Conversion c = op.specialize(op.getSourceCRS(), changeCS(op.getTargetCRS(), HardCodedCS.GEODETIC_φλ), null);
        assertMatrixEquals(Matrices.create(3, 4, new double[] {
                    0, 1, 0, 0,
                    1, 0, 0, OFFSET,
                    0, 0, 0, 1
                }), MathTransforms.getMatrix(c.getMathTransform()), STRICT,
                "Longitude rotation of a two-dimensional CRS");
    }


    /**
     * Tests {@link DefaultConversion#specialize DefaultConversion.specialize(…)} with an interpolation CRS.
     * In this test, we invent an imaginary scenario where the longitude rotation to apply varies with time
     * (a "moving prime meridian").
     *
     * <h4>Note</h4>
     * From some point of view, this scenario is not as weird as it may look like. The Greenwich prime meridian
     * was initially the meridian passing through the telescope of the Greenwich observatory. But when a new
     * more powerful telescopes was built, is was installed a few metres far from the old one. So if we were
     * staying to a strict interpretation like "the meridian passing through the main telescope",
     * that meridian would indeed more with time.
     *
     * @throws FactoryException if an error occurred while creating the conversion.
     */
    @Test
    public void testWithInterpolationCRS() throws FactoryException {
        DefaultConversion op = createLongitudeRotation(HardCodedCRS.NTF_NORMALIZED_AXES,
                createParisCRS(false, HardCodedCS.GEODETIC_2D, true), HardCodedCRS.TIME);
        assertMatrixEquals(new Matrix4(1, 0, 0, 0,
                                       0, 1, 0, OFFSET,
                                       0, 0, 1, 0,
                                       0, 0, 0, 1),
                MathTransforms.getMatrix(op.getMathTransform()), STRICT,
                "Longitude rotation of a time-varying CRS");

        Conversion c = op.specialize(
                op.getSourceCRS(),          // Keep the same source CRS.
                changeCS(op.getTargetCRS(), HardCodedCS.GEODETIC_φλ),   // Swap axis order.
                null);

        assertMatrixEquals(new Matrix4(1, 0, 0, 0,
                                       0, 0, 1, 0,
                                       0, 1, 0, OFFSET,
                                       0, 0, 0, 1),
                MathTransforms.getMatrix(c.getMathTransform()), STRICT,
                "Longitude rotation of a time-varying CRS");
    }

    /**
     * Ensures that {@link DefaultConversion#specialize DefaultConversion.specialize(…)} verifies the datum.
     *
     * @throws FactoryException if an error occurred while creating the conversion.
     */
    @Test
    public void testDatumCheck() throws FactoryException {
        final DefaultConversion op = createLongitudeRotation(true);
        IllegalArgumentException e;
        e = assertThrows(IllegalArgumentException.class,
                () -> op.specialize(HardCodedCRS.WGS84, HardCodedCRS.NTF_NORMALIZED_AXES, null),
                "Should not have accepted to change the geodetic reference frame.");
        assertMessageContains(e, "sourceCRS", "Nouvelle Triangulation Française");

        e = assertThrows(IllegalArgumentException.class,
                () -> op.specialize(HardCodedCRS.NTF_NORMALIZED_AXES, HardCodedCRS.WGS84, null),
                "Should not have accepted to change the geodetic reference frame.");
        assertMessageContains(e, "targetCRS", "Nouvelle Triangulation Française");
    }

    /**
     * Tests serialization.
     */
    @Test
    public void testSerialization() {
        verifyProperties(assertSerializedEquals(createLongitudeRotation(false)), false);
    }
}
