/*
 * 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.test.integration;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.text.ParseException;
import javax.measure.Quantity;
import javax.measure.Unit;
import javax.measure.UnitConverter;
import org.opengis.metadata.Identifier;
import org.opengis.util.FactoryException;
import org.opengis.util.NoSuchIdentifierException;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.apache.sis.referencing.factory.FactoryDataException;
import org.apache.sis.referencing.factory.UnavailableFactoryException;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.referencing.CRS;
import org.apache.sis.io.wkt.Convention;
import org.apache.sis.io.wkt.Warnings;
import org.apache.sis.io.wkt.WKTFormat;
import org.apache.sis.io.TableAppender;
import org.apache.sis.io.wkt.UnformattableObjectException;
import org.apache.sis.util.ComparisonMode;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.Utilities;
import org.apache.sis.test.DependsOn;
import org.apache.sis.test.TestCase;
import org.junit.Test;

import static org.junit.Assert.*;
import static org.junit.Assume.assumeTrue;


/**
 * Performs consistency checks on all CRS given by {@link CRS#getAuthorityFactory(String)}.
 * The consistency checks include:
 *
 * <ul>
 *   <li>Format in WKT, parse, reformat again and verify that we get the same WKT string.</li>
 * </ul>
 *
 * This test is executed only if {@link #RUN_EXTENSIVE_TESTS} is {@code true}.
 *
 * @author  Martin Desruisseaux (Geomatys)
 * @version 1.0
 * @since   0.7
 * @module
 */
@DependsOn({
    org.apache.sis.referencing.CRSTest.class,
    org.apache.sis.io.wkt.WKTFormatTest.class
})
public final strictfp class ConsistencyTest extends TestCase {
    /**
     * Codes to exclude for now.
     */
    private static final Set<String> EXCLUDES = new HashSet<>(Arrays.asList(
        "CRS:1",            // Computer display: WKT parser alters the (i,j) axis names.
        "EPSG:5819",        // EPSG topocentric example A: error while parsing WKT.
        "AUTO2:42001",      // This projection requires parameters, but we provide none.
        "AUTO2:42002",      // This projection requires parameters, but we provide none.
        "AUTO2:42003",      // This projection requires parameters, but we provide none.
        "AUTO2:42004",      // This projection requires parameters, but we provide none.
        "AUTO2:42005"       // This projection requires parameters, but we provide none.
    ));

    /**
     * Width of the code columns in the warnings formatted by {@link #print(String, String, Object)}.
     * We begin with an arbitrary width and will expand if necessary.
     */
    private int codeWidth = 15;

    /**
     * Specialization of {@link #testCoordinateReferenceSystems()} for specific cases that were known to fail.
     * This is used for debugging purposes only; not included in normal test execution because it is redundant
     * with {@link #testCoordinateReferenceSystems()}.
     *
     * @throws FactoryException if the coordinate reference system can not be created.
     *
     * @see <a href="https://issues.apache.org/jira/browse/SIS-433">SIS-433</a>
     * @see <a href="https://issues.apache.org/jira/browse/SIS-434">SIS-434</a>
     */
    public void debug() throws FactoryException {
        final String code = "EPSG::29871";
        final CoordinateReferenceSystem crs = CRS.forCode(code);
        final WKTFormat format = new WKTFormat(null, null);
        format.setConvention(Convention.WKT2);
        lookup(parseAndFormat(format, code, crs), crs);
    }

    /**
     * Verifies the WKT consistency of all CRS instances.
     *
     * @throws FactoryException if an error other than "unsupported operation method" occurred.
     */
    @Test
    public void testCoordinateReferenceSystems() throws FactoryException {
        assumeTrue(RUN_EXTENSIVE_TESTS);
        final WKTFormat v1  = new WKTFormat(null, null);
        final WKTFormat v1c = new WKTFormat(null, null);
        final WKTFormat v2  = new WKTFormat(null, null);
        final WKTFormat v2s = new WKTFormat(null, null);
        v1 .setConvention(Convention.WKT1);
        v1c.setConvention(Convention.WKT1_COMMON_UNITS);
        v2 .setConvention(Convention.WKT2);
        v2s.setConvention(Convention.WKT2_SIMPLIFIED);
        for (final String code : CRS.getAuthorityFactory(null).getAuthorityCodes(CoordinateReferenceSystem.class)) {
            if (!EXCLUDES.contains(code) && !code.startsWith("Proj4:")) {
                final CoordinateReferenceSystem crs;
                try {
                    crs = CRS.forCode(code);
                } catch (UnavailableFactoryException | NoSuchIdentifierException | FactoryDataException e) {
                    print(code, "WARNING", e.getLocalizedMessage());
                    continue;
                }
                lookup(parseAndFormat(v2,  code, crs), crs);
                lookup(parseAndFormat(v2s, code, crs), crs);
                /*
                 * There is more information lost in WKT 1 than in WKT 2, so we can not test everything.
                 * For example we can not format fully three-dimensional geographic CRS because the unit
                 * is not the same for all axes. We can not format neither some axis directions.
                 */
                try {
                    parseAndFormat(v1, code, crs);
                } catch (UnformattableObjectException e) {
                    print(code, "WARNING", e.getLocalizedMessage());
                    continue;
                }
                parseAndFormat(v1c, code, crs);
            }
        }
    }

    /**
     * Prints the given code followed by spaces and the given {@code "ERROR"} or {@code "WARNING"} word,
     * then the given message.
     */
    private void print(final String code, final String word, final Object message) {
        final int currentWidth = code.length();
        if (currentWidth >= codeWidth) {
            codeWidth = currentWidth + 1;
        }
        out.print(code);
        out.print(CharSequences.spaces(codeWidth - currentWidth));
        out.print(word);
        out.print(": ");
        out.println(message);
    }

    /**
     * Formats the given CRS using the given formatter, parses it and reformats again.
     * Then the two WKT are compared.
     *
     * @param  f     the formatter to use.
     * @param  code  the authority code, used only in case of errors.
     * @param  crs   the CRS to test.
     * @return the parsed CRS.
     */
    private CoordinateReferenceSystem parseAndFormat(final WKTFormat f,
            final String code, final CoordinateReferenceSystem crs)
    {
        String wkt = f.format(crs);
        final Warnings warnings = f.getWarnings();
        if (warnings != null && !warnings.getExceptions().isEmpty()) {
            print(code, "WARNING", warnings.getException(0));
        }
        final CoordinateReferenceSystem parsed;
        try {
            parsed = (CoordinateReferenceSystem) f.parseObject(wkt);
        } catch (ParseException e) {
            print(code, "ERROR", "Can not parse the WKT below.");
            out.println(wkt);
            out.println();
            e.printStackTrace(out);
            fail(e.getLocalizedMessage());
            return null;
        }
        final String again = f.format(parsed);
        final CharSequence[] expectedLines = CharSequences.splitOnEOL(wkt);
        final CharSequence[] actualLines   = CharSequences.splitOnEOL(again);
        /*
         * WKT 2 contains a line like below:
         *
         *   METHOD["Transverse Mercator", ID["EPSG", 9807, "8.9"]]
         *
         * But after parsing, the version number disaspear:
         *
         *   METHOD["Transverse Mercator", ID["EPSG", 9807]]
         *
         * This is a side effect of the fact that operation method are hard-coded in Java code.
         * This is normal for our implementation, so remove the version number from the expected lines.
         */
        if (f.getConvention().majorVersion() >= 2) {
            for (int i=0; i < expectedLines.length; i++) {
                final CharSequence line = expectedLines[i];
                int p = line.length();
                int s = CharSequences.skipLeadingWhitespaces(line, 0, p);
                if (CharSequences.regionMatches(line, s, "METHOD[\"")) {
                    assertEquals(code, ',', line.charAt(--p));
                    assertEquals(code, ']', line.charAt(--p));
                    assertEquals(code, ']', line.charAt(--p));
                    if (line.charAt(--p) == '"') {
                        p = CharSequences.lastIndexOf(line, ',', 0, p);
                        expectedLines[i] = line.subSequence(0, p) + "]],";
                    }
                }
            }
        }
        /*
         * Now compare the WKT line-by-line.
         */
        final int length = StrictMath.min(expectedLines.length, actualLines.length);
        try {
            for (int i=0; i<length; i++) {
                assertEquals(code, expectedLines[i], actualLines[i]);
            }
        } catch (AssertionError e) {
            print(code, "ERROR", "WKT are not equal.");
            final TableAppender table = new TableAppender();
            table.nextLine('═');
            table.setMultiLinesCells(true);
            table.append("Original WKT:");
            table.nextColumn();
            table.append("CRS parsed from the WKT:");
            table.nextLine();
            table.appendHorizontalSeparator();
            table.append(wkt);
            table.nextColumn();
            table.append(again);
            table.nextLine();
            table.nextLine('═');
            out.println(table);
            throw e;
        }
        assertEquals("Unexpected number of lines.", expectedLines.length, actualLines.length);
        return parsed;
    }

    /**
     * Verifies that {@code IdentifiedObjects.lookupURN(…)} on the parsed CRS can find back the original CRS.
     */
    private void lookup(final CoordinateReferenceSystem parsed, final CoordinateReferenceSystem crs) throws FactoryException {
        final Identifier id = IdentifiedObjects.getIdentifier(crs, null);
        final String urn = IdentifiedObjects.toURN(crs.getClass(), id);
        assertNotNull(crs.getName().getCode(), urn);
        /*
         * Lookup operation is not going to work if the CRS are not approximately equal.
         * However in current Apache SIS implementation, we can perform this check only
         * if the scale factor of units of measurement have the exact same value.
         *
         * This check can be removed after the following issue is resolved:
         * https://issues.apache.org/jira/browse/SIS-433
         */
        if (toStandardUnit(crs   .getCoordinateSystem().getAxis(0).getUnit()).equals(
            toStandardUnit(parsed.getCoordinateSystem().getAxis(0).getUnit())))
        {
            assertTrue(urn, Utilities.deepEquals(crs, parsed, ComparisonMode.DEBUG));
            /*
             * Now test the lookup operation. Since the parsed CRS has an identifier,
             * that lookup operation should not do a lot of work actually.
             */
            final String lookup = IdentifiedObjects.lookupURN(parsed, null);
            assertEquals("Failed to lookup the parsed CRS.", urn, lookup);
        } else {
            print(id.getCode(), "SKIPPED", "Unit conversion factors differ.");
        }
    }

    /**
     * Returns the converter to standard unit.
     */
    private static <Q extends Quantity<Q>> UnitConverter toStandardUnit(final Unit<Q> unit) {
        return unit.getConverterTo(unit.getSystemUnit());
    }
}
