/*
 * 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.Map;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Arrays;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.io.IOException;
import java.nio.ByteOrder;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.channels.ReadableByteChannel;
import java.nio.charset.StandardCharsets;
import javax.xml.bind.annotation.XmlTransient;
import javax.measure.Unit;
import javax.measure.quantity.Angle;
import org.opengis.util.FactoryException;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.parameter.ParameterDescriptor;
import org.opengis.parameter.ParameterDescriptorGroup;
import org.opengis.parameter.ParameterNotFoundException;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.MathTransformFactory;
import org.opengis.referencing.operation.Transformation;
import org.opengis.referencing.operation.NoninvertibleTransformException;
import org.apache.sis.referencing.operation.transform.InterpolatedTransform;
import org.apache.sis.internal.system.Loggers;
import org.apache.sis.internal.system.DataDirectory;
import org.apache.sis.internal.referencing.Formulas;
import org.apache.sis.parameter.ParameterBuilder;
import org.apache.sis.parameter.Parameters;
import org.apache.sis.util.collection.Cache;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.resources.Messages;
import org.apache.sis.measure.Units;


/**
 * The provider for <cite>"National Transformation version 2"</cite> (EPSG:9615).
 * This transform requires data that are not bundled by default with Apache SIS.
 *
 * @author  Simon Reynard (Geomatys)
 * @author  Martin Desruisseaux (Geomatys)
 * @version 1.0
 * @since   0.7
 * @module
 */
@XmlTransient
public final class NTv2 extends AbstractProvider {
    /**
     * Serial number for inter-operability with different versions.
     */
    private static final long serialVersionUID = -4027618007780159180L;

    /**
     * The operation parameter descriptor for the <cite>"Latitude and longitude difference file"</cite> parameter value.
     * The file extension is typically {@code ".gsb"}. There is no default value.
     */
    private static final ParameterDescriptor<Path> FILE;

    /**
     * The group of all parameters expected by this coordinate operation.
     */
    public static final ParameterDescriptorGroup PARAMETERS;
    static {
        final ParameterBuilder builder = builder();
        FILE = builder
                .addIdentifier("8656")
                .addName("Latitude and longitude difference file")
                .create(Path.class, null);
        PARAMETERS = builder
                .addIdentifier("9615")
                .addName("NTv2")
                .createGroup(FILE);
    }

    /**
     * Creates a new provider.
     */
    public NTv2() {
        super(2, 2, PARAMETERS);
    }

    /**
     * Returns the base interface of the {@code CoordinateOperation} instances that use this method.
     *
     * @return fixed to {@link Transformation}.
     */
    @Override
    public Class<Transformation> getOperationType() {
        return Transformation.class;
    }

    /**
     * Creates a transform from the specified group of parameter values.
     *
     * @param  factory  the factory to use if this constructor needs to create other math transforms.
     * @param  values   the group of parameter values.
     * @return the created math transform.
     * @throws ParameterNotFoundException if a required parameter was not found.
     * @throws FactoryException if an error occurred while loading the grid.
     */
    @Override
    public MathTransform createMathTransform(final MathTransformFactory factory, final ParameterValueGroup values)
            throws ParameterNotFoundException, FactoryException
    {
        final Parameters pg = Parameters.castOrWrap(values);
        return InterpolatedTransform.createGeodeticTransformation(factory, getOrLoad(pg.getMandatoryValue(FILE)));
    }

    /**
     * Returns the grid of the given name. This method returns the cached instance if it still exists,
     * or load the grid otherwise.
     *
     * @param  file  name of the datum shift grid file to load.
     */
    @SuppressWarnings("null")
    static DatumShiftGridFile<Angle,Angle> getOrLoad(final Path file) throws FactoryException {
        final Path resolved = DataDirectory.DATUM_CHANGES.resolve(file).toAbsolutePath();
        DatumShiftGridFile<?,?> grid = DatumShiftGridFile.CACHE.peek(resolved);
        if (grid == null) {
            final Cache.Handler<DatumShiftGridFile<?,?>> handler = DatumShiftGridFile.CACHE.lock(resolved);
            try {
                grid = handler.peek();
                if (grid == null) {
                    try (ReadableByteChannel in = Files.newByteChannel(resolved)) {
                        DatumShiftGridLoader.log(NTv2.class, file);
                        final Loader loader = new Loader(in, file);
                        grid = loader.readGrid();
                        loader.reportWarnings();
                    } catch (IOException | NoninvertibleTransformException | RuntimeException e) {
                        throw DatumShiftGridLoader.canNotLoad("NTv2", file, e);
                    }
                    grid = grid.useSharedData();
                }
            } finally {
                handler.putAndUnlock(grid);
            }
        }
        return grid.castTo(Angle.class, Angle.class);
    }




    /**
     * Loaders of NTv2 data. Instances of this class exist only at loading time.
     *
     * @author  Simon Reynard (Geomatys)
     * @author  Martin Desruisseaux (Geomatys)
     * @version 1.0
     * @since   0.7
     * @module
     */
    private static final class Loader extends DatumShiftGridLoader {
        /**
         * Size of a record. This value applies to both the header records and the data records.
         * In the case of header records, this is the size of the key plus the size of the value.
         */
        private static final int RECORD_LENGTH = 16;

        /**
         * Maximum number of characters of a key in a header record.
         */
        private static final int KEY_LENGTH = 8;

        /**
         * Type of data allowed in header records.
         */
        private static final int STRING_TYPE = 0, INTEGER_TYPE = 1, DOUBLE_TYPE = 2;

        /**
         * Some known keywords that may appear in NTv2 header records.
         */
        private static final Map<String,Integer> TYPES;
        static {
            final Map<String,Integer> types = new HashMap<>(32);
            final Integer string  = STRING_TYPE;    // Autoboxing
            final Integer integer = INTEGER_TYPE;
            final Integer real    = DOUBLE_TYPE;
            types.put("NUM_OREC", integer);         // Number of records in the header - usually 11
            types.put("NUM_SREC", integer);         // Number of records in the header of sub-grids - usually 11
            types.put("NUM_FILE", integer);         // Number of sub-grids
            types.put("GS_TYPE",  string);          // Units: "SECONDS", "MINUTES" or "DEGREES"
            types.put("VERSION",  string);          // Grid version
            types.put("SYSTEM_F", string);          // Source CRS
            types.put("SYSTEM_T", string);          // Target CRS
            types.put("MAJOR_F",  real);            // Semi-major axis of source ellipsoid (in metres)
            types.put("MINOR_F",  real);            // Semi-minor axis of source ellipsoid (in metres)
            types.put("MAJOR_T",  real);            // Semi-major axis of target ellipsoid (in metres)
            types.put("MINOR_T",  real);            // Semi-minor axis of target ellipsoid (in metres)
            types.put("SUB_NAME", string);          // Sub-grid identifier
            types.put("PARENT",   string);          // Parent grid
            types.put("CREATED",  string);          // Creation time
            types.put("UPDATED",  string);          // Update time
            types.put("S_LAT",    real);            // Southmost φ value
            types.put("N_LAT",    real);            // Northmost φ value
            types.put("E_LONG",   real);            // Eastmost λ value - west is positive, east is negative
            types.put("W_LONG",   real);            // Westmost λ value - west is positive, east is negative
            types.put("LAT_INC",  real);            // Increment on φ axis
            types.put("LONG_INC", real);            // Increment on λ axis - positive toward west
            types.put("GS_COUNT", integer);         // Number of sub-grid records following
            TYPES = types;
        }

        /**
         * The header content. Keys are strings like {@code "VERSION"}, {@code "SYSTEM_F"},
         * <var>etc.</var>. Values are {@link String}, {@link Integer} or {@link Double}.
         * If some keys are unrecognized, they will be put in this map with the {@code null} value
         * and the {@link #hasUnrecognized} field will be set to {@code true}.
         */
        private final Map<String,Object> header;

        /**
         * {@code true} if the {@code header} map contains at least one key associated to a null value.
         */
        private boolean hasUnrecognized;

        /**
         * Number of grids remaining in the file. This value is set in the constructor,
         * then decremented at every call to {@link #readGrid()}.
         */
        private int remainingGrids;

        /**
         * Creates a new reader for the given channel.
         * This constructor parses the header immediately, but does not read any grid.
         *
         * @param  channel  where to read data from.
         * @param  file     path to the longitude and latitude difference file. Used for parameter declaration and error reporting.
         * @throws FactoryException if a data record can not be parsed.
         */
        Loader(final ReadableByteChannel channel, final Path file) throws IOException, FactoryException {
            super(channel, ByteBuffer.allocate(4096), file);
            this.header = new LinkedHashMap<>();
            ensureBufferContains(RECORD_LENGTH);
            if (isLittleEndian(buffer.getInt(KEY_LENGTH))) {
                buffer.order(ByteOrder.LITTLE_ENDIAN);
            }
            /*
             * Read the overview header. It is normally made of the first 11 records documented in TYPES map:
             * NUM_OREC, NUM_SREC, NUM_FILE, GS_TYPE, VERSION, SYSTEM_F, SYSTEM_T, MAJOR_F, MINOR_F, MAJOR_T,
             * MINOR_T.
             */
            readHeader(11, "NUM_OREC");
            remainingGrids = (Integer) get("NUM_FILE");
            if (remainingGrids < 1) {
                throw new FactoryException(Errors.format(Errors.Keys.UnexpectedValueInElement_2, "NUM_FILE", remainingGrids));
            }
        }

        /**
         * Reads the next grid, starting at the current position. A NTv2 file can have many grids.
         * This can be used for grids having different resolutions depending on the geographic area.
         * The first grid can cover a large area with a coarse resolution, and next grids cover smaller
         * areas overlapping the first grid but with finer resolution.
         *
         * Current SIS implementation does not yet handle the above-cited hierarchy of grids.
         * For now we just take the first one.
         *
         * <p>NTv2 grids contain also information about shifts accuracy. This is not yet handled by SIS,
         * except for determining an approximate grid cell resolution.</p>
         */
        final DatumShiftGridFile<Angle,Angle> readGrid() throws IOException, FactoryException, NoninvertibleTransformException {
            if (--remainingGrids < 0) {
                throw new FactoryException(Errors.format(Errors.Keys.CanNotRead_1, file));
            }
            final Object[] overviewKeys = header.keySet().toArray();
            readHeader((Integer) get("NUM_SREC"), "NUM_SREC");
            /*
             * Extract the geographic bounding box and cell size. While different units are allowed,
             * in practice we usually have seconds of angle. This units has the advantage of allowing
             * all floating-point values to be integers.
             *
             * Note that the longitude values in NTv2 files are positive WEST.
             */
            final Unit<Angle> unit;
            final double precision;
            final String name = (String) get("GS_TYPE");
            if (name.equalsIgnoreCase("SECONDS")) {                 // Most common value
                unit = Units.ARC_SECOND;
                precision = SECOND_PRECISION;                       // Used only as a hint; will not hurt if wrong.
            } else if (name.equalsIgnoreCase("MINUTES")) {
                unit = Units.ARC_MINUTE;
                precision = SECOND_PRECISION / 60;                  // Used only as a hint; will not hurt if wrong.
            } else if (name.equalsIgnoreCase("DEGREES")) {
                unit = Units.DEGREE;
                precision = SECOND_PRECISION / DEGREES_TO_SECONDS;  // Used only as a hint; will not hurt if wrong.
            } else {
                throw new FactoryException(Errors.format(Errors.Keys.UnexpectedValueInElement_2, "GS_TYPE", name));
            }
            final double  ymin     = (Double)  get("S_LAT");
            final double  ymax     = (Double)  get("N_LAT");
            final double  xmin     = (Double)  get("E_LONG");       // Sign reversed compared to usual convention.
            final double  xmax     = (Double)  get("W_LONG");       // Idem.
            final double  dy       = (Double)  get("LAT_INC");
            final double  dx       = (Double)  get("LONG_INC");     // Positive toward west.
            final Integer declared = (Integer) header.get("GS_COUNT");
            final int     width    = Math.toIntExact(Math.round((xmax - xmin) / dx + 1));
            final int     height   = Math.toIntExact(Math.round((ymax - ymin) / dy + 1));
            final int     count    = Math.multiplyExact(width, height);
            if (declared != null && count != declared) {
                throw new FactoryException(Errors.format(Errors.Keys.UnexpectedValueInElement_2, "GS_COUNT", declared));
            }
            /*
             * Construct the grid. The sign of longitude translations will need to be reversed in order to have
             * longitudes increasing toward East. We set isCellValueRatio = true (by the arguments given to the
             * DatumShiftGridFile constructor) because this is required by InterpolatedTransform implementation.
             * This setting implies that we divide translation values by dx or dy at reading time. Note that this
             * free us from reversing the sign of longitude translations in the code below; instead, this reversal
             * will be handled by grid.coordinateToGrid MathTransform and its inverse.
             */
            final DatumShiftGridFile.Float<Angle,Angle> grid = new DatumShiftGridFile.Float<>(2,
                    unit, unit, true, -xmin, ymin, -dx, dy, width, height, PARAMETERS, file);
            @SuppressWarnings("MismatchedReadAndWriteOfArray") final float[] tx = grid.offsets[0];
            @SuppressWarnings("MismatchedReadAndWriteOfArray") final float[] ty = grid.offsets[1];
            for (int i=0; i<count; i++) {
                ensureBufferContains(4 * Float.BYTES);
                ty[i] = (float) (buffer.getFloat() / dy);   // Division by dx and dy because isCellValueRatio = true.
                tx[i] = (float) (buffer.getFloat() / dx);
                final double accuracy = Math.min(buffer.getFloat() / dy, buffer.getFloat() / dx);
                if (accuracy > 0 && !(accuracy >= grid.accuracy)) {   // Use '!' for replacing the initial NaN.
                    grid.accuracy = accuracy;                         // Smallest non-zero accuracy.
                }
            }
            /*
             * We need an estimation of translation accuracy, in order to decide when to stop iterations
             * during inverse transformations. If we did not found that information in the file, compute
             * an arbitrary default accuracy.
             */
            final double size = Math.max(dx, dy);
            if (Double.isNaN(grid.accuracy)) {
                grid.accuracy = Units.DEGREE.getConverterTo(unit).convert(Formulas.ANGULAR_TOLERANCE) / size;
            }
            header.keySet().retainAll(Arrays.asList(overviewKeys));   // Keep only overview records.
            return DatumShiftGridCompressed.compress(grid, null, precision / size);
        }

        /**
         * Returns {@code true} if the given value seems to be stored in little endian order.
         */
        private static boolean isLittleEndian(final int n) {
            return Integer.compareUnsigned(n, Integer.reverseBytes(n)) > 0;
        }

        /**
         * Reads a string at the given position in the buffer.
         */
        private String readString(final int position, int length) {
            final byte[] array = buffer.array();
            while (length > position && array[position + length - 1] <= ' ') length--;
            return new String(array, position, length, StandardCharsets.US_ASCII).trim();
        }

        /**
         * Reads all records found in the header, starting from the current buffer position.
         * It may be the overview header (in which case we expect {@code NUM_OREC} records)
         * or a sub-grid header (in which case we expect {@code NUM_SREC} records).
         *
         * @param  numRecords  default number of expected records (usually 11).
         * @param  numkey      key of the record giving the number of records: {@code "NUM_OREC"} or {@code "NUM_SREC"}.
         */
        private void readHeader(int numRecords, final String numkey) throws IOException, FactoryException {
            int position = buffer.position();
            for (int i=0; i < numRecords; i++) {
                ensureBufferContains(RECORD_LENGTH);
                final String key = readString(position, KEY_LENGTH).toUpperCase(Locale.US);
                position += KEY_LENGTH;
                final Integer type = TYPES.get(key);
                final Comparable<?> value;
                if (type == null) {
                    value = null;
                    hasUnrecognized = true;
                } else switch (type) {
                    case STRING_TYPE: {
                        value = readString(position, RECORD_LENGTH - KEY_LENGTH);
                        break;
                    }
                    case INTEGER_TYPE: {
                        final int n = buffer.getInt(position);
                        if (key.equals(numkey)) {
                            numRecords = n;
                        }
                        value = n;
                        break;
                    }
                    case DOUBLE_TYPE: {
                        value = buffer.getDouble(position);
                        break;
                    }
                    default: throw new AssertionError(type);
                }
                final Object old = header.put(key, value);
                if (old != null && !old.equals(value)) {
                    throw new FactoryException(Errors.format(Errors.Keys.KeyCollision_1, key));
                }
                buffer.position(position += RECORD_LENGTH - KEY_LENGTH);
            }
        }

        /**
         * Returns the value for the given key, or thrown an exception if the value is not found.
         */
        private Object get(final String key) throws FactoryException {
            final Object value = header.get(key);
            if (value != null) {
                return value;
            }
            throw new FactoryException(Errors.format(Errors.Keys.PropertyNotFound_2, file, key));
        }

        /**
         * If we had any warnings during the loading process, report them now.
         */
        void reportWarnings() {
            if (hasUnrecognized) {
                final StringBuilder keywords = new StringBuilder();
                for (final Map.Entry<String,Object> entry : header.entrySet()) {
                    if (entry.getValue() == null) {
                        if (keywords.length() != 0) {
                            keywords.append(", ");
                        }
                        keywords.append(entry.getKey());
                    }
                }
                final LogRecord record = Messages.getResources(null).getLogRecord(Level.WARNING,
                        Messages.Keys.UnknownKeywordInRecord_2, file, keywords.toString());
                record.setLoggerName(Loggers.COORDINATE_OPERATION);
                Logging.log(NTv2.class, "createMathTransform", record);
            }
        }
    }
}
