/*
 * 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 java.io.IOException;
import java.nio.ByteOrder;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import java.net.URI;
import java.nio.file.NoSuchFileException;
import java.nio.channels.ReadableByteChannel;
import jakarta.xml.bind.annotation.XmlTransient;
import javax.measure.quantity.Angle;
import org.opengis.util.FactoryException;
import org.opengis.parameter.ParameterDescriptor;
import org.opengis.parameter.ParameterDescriptorGroup;
import org.opengis.parameter.ParameterNotFoundException;
import org.opengis.referencing.cs.EllipsoidalCS;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.Transformation;
import org.opengis.referencing.operation.NoninvertibleTransformException;
import org.apache.sis.referencing.factory.MissingFactoryResourceException;
import org.apache.sis.referencing.operation.gridded.CompressedGrid;
import org.apache.sis.referencing.operation.gridded.GridLoader;
import org.apache.sis.referencing.operation.gridded.LoadedGrid;
import org.apache.sis.referencing.operation.gridded.GridFile;
import org.apache.sis.parameter.ParameterBuilder;
import org.apache.sis.parameter.Parameters;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.measure.Units;


/**
 * The provider for <q>North American Datum Conversion</q> (EPSG:9613).
 * This transform requires data that are not bundled by default with Apache SIS.
 *
 * <p>The files given in parameters are theoretically binary files. However, this provider accepts also ASCII files.
 * Those two kinds of files are recognized automatically; there is no need to specify whether the files are ASCII
 * or binary.</p>
 *
 * @author  Martin Desruisseaux (Geomatys)
 * @author  Rueben Schulz (UBC)
 *
 * @see <a href="http://www.ngs.noaa.gov/cgi-bin/nadcon.prl">NADCON on-line computation</a>
 */
@XmlTransient
public final class NADCON extends AbstractProvider {
    /**
     * Serial number for inter-operability with different versions.
     */
    private static final long serialVersionUID = -4707304160205218546L;

    /**
     * The operation parameter descriptor for the <q>Latitude difference file</q> parameter value.
     * The default value is {@code "conus.las"}.
     *
     * <!-- Generated by ParameterNameTableGenerator -->
     * <table class="sis">
     *   <caption>Parameter names</caption>
     *   <tr><td> EPSG:    </td><td> Latitude difference file </td></tr>
     * </table>
     * <b>Notes:</b>
     * <ul>
     *   <li>Default value: {@code conus.las}</li>
     * </ul>
     */
    private static final ParameterDescriptor<URI> LATITUDE;

    /**
     * The operation parameter descriptor for the <q>Longitude difference file</q> parameter value.
     * The default value is {@code "conus.los"}.
     *
     * <!-- Generated by ParameterNameTableGenerator -->
     * <table class="sis">
     *   <caption>Parameter names</caption>
     *   <tr><td> EPSG:    </td><td> Longitude difference file </td></tr>
     * </table>
     * <b>Notes:</b>
     * <ul>
     *   <li>Default value: {@code conus.los}</li>
     * </ul>
     */
    private static final ParameterDescriptor<URI> LONGITUDE;

    /**
     * The group of all parameters expected by this coordinate operation.
     */
    private static final ParameterDescriptorGroup PARAMETERS;
    static {
        final ParameterBuilder builder = builder();
        LATITUDE = builder
                .addIdentifier("8657")
                .addName("Latitude difference file")
                .create(URI.class, URI.create("conus.las"));
        LONGITUDE = builder
                .addIdentifier("8658")
                .addName("Longitude difference file")
                .create(URI.class, URI.create("conus.los"));
        PARAMETERS = builder
                .addIdentifier("9613")
                .addName("NADCON")
                .addName("Geographic2D Offsets (NADCON)")
                .createGroup(LATITUDE, LONGITUDE);
    }

    /**
     * Creates a new provider.
     */
    public NADCON() {
        super(Transformation.class, PARAMETERS,
              EllipsoidalCS.class, false,
              EllipsoidalCS.class, false,
              (byte) 2);
    }

    /**
     * Creates a transform from the specified group of parameter values.
     *
     * @param  context  the parameter values together with its context.
     * @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 Context context) throws FactoryException {
        final Parameters pg = Parameters.castOrWrap(context.getCompletedParameters());
        final GridFile latitudeShifts  = new GridFile(pg, LATITUDE);
        final GridFile longitudeShifts = new GridFile(pg, LONGITUDE);
        try {
            LoadedGrid<Angle,Angle> grid = getOrLoad(latitudeShifts, longitudeShifts);
            return LoadedGrid.createGeodeticTransformation(NADCON.class, context.getFactory(), grid);
        } catch (NoSuchFileException e) {
            throw new MissingFactoryResourceException(e.getMessage(), e);
        } catch (FactoryException e) {
            throw e;
        } catch (Exception e) {
            throw new FactoryException(e);
        }
    }

    /**
     * Returns the grid of the given name.
     * This method returns the cached instance if it still exists, or load the grid otherwise.
     *
     * @param  latitudeShifts   relative or absolute path of the grid file for latitude shifts.
     * @param  longitudeShifts  relative or absolute path name of the grid file for longitude shifts.
     * @throws Exception if an error occurred while loading the grid.
     */
    static LoadedGrid<Angle,Angle> getOrLoad(final GridFile latitudeShifts, final GridFile longitudeShifts)
            throws Exception
    {
        return LoadedGrid.getOrLoad(latitudeShifts, longitudeShifts, () -> {
            final Loader loader;
            GridFile file = latitudeShifts;         // Used if an error needs to be reported.
            final LoadedGrid<?,?> grid;
            try {
                // Note: buffer size must be divisible by the size of `float` data type.
                final ByteBuffer buffer = ByteBuffer.allocate(4096).order(ByteOrder.LITTLE_ENDIAN);
                final FloatBuffer fb = buffer.asFloatBuffer();
                try (ReadableByteChannel in = latitudeShifts.newByteChannel()) {
                    GridFile.startLoading(NADCON.class, CharSequences.commonPrefix(
                            latitudeShifts.toString(), longitudeShifts.toString()).toString() + '…');
                    loader = new Loader(in, buffer, latitudeShifts);
                    loader.readGrid(fb, null, longitudeShifts);
                }
                buffer.clear();
                file = longitudeShifts;
                try (ReadableByteChannel in = longitudeShifts.newByteChannel()) {
                    new Loader(in, buffer, longitudeShifts).readGrid(fb, loader, null);
                }
            } catch (IOException | NoninvertibleTransformException | RuntimeException e) {
                /*
                 * Handle the exception here instead of by the caller
                 * because we know which of the 2 files is problematic.
                 */
                throw file.canNotLoad(NADCON.class, "NADCON", e);
            }
            grid = CompressedGrid.compress(loader.grid, null, loader.grid.accuracy);
            return grid.useSharedData();
        }).castTo(Angle.class, Angle.class);
    }




    /**
     * Loaders of NADCON data. Instances of this class exist only at loading time.
     * This class can read both binary and ASCII grid files.
     *
     * <h4>Binary format</h4>
     * NADCON binary files ({@code "*.las"} and {@code "*.los"}) are organized into records
     * with the first record containing the header information, followed by the shift data.
     * The length of each record (including header) depends on the number of columns.
     *
     * <p>Records are ordered from South to North. Each record (except the header) is an entire row of grid points,
     * with values ordered from West to East. Each value is a {@code float} encoded in little endian byte order.
     * Each record ends with a 4 byte separator.</p>
     *
     * <p>Record data use a different convention than the record header. The header uses degrees of angle with
     * positive values East. But the offset values after the header are in seconds of angle with positive values
     * West. The {@code DatumShiftGrid} returned by this loader uses the header convention, which also matches
     * the order in which offset values appear in each row.</p>
     *
     * @author  Martin Desruisseaux (Geomatys)
     * @author  Rueben Schulz (UBC)
     */
    private static final class Loader extends GridLoader {
        /**
         * The length of the description in the header, in bytes.
         * <ul>
         *   <li>56 bytes of NADCON's identification: {@code "NADCON EXTRACTED REGION"}.</li>
         *   <li>8 bytes of "PGM" (unknown meaning).</li>
         * </ul>
         */
        private static final int DESCRIPTION_LENGTH = 64;

        /**
         * The first bytes that we expect to find the header (both binary and ASCII files).
         * We use that as the format signature.
         */
        private static final String NADCON = "NADCON";

        /**
         * Longitude and latitude of of the first value in the first record, in degrees.
         */
        private final float x0, y0;

        /**
         * The difference between longitude (<var>Δx</var>) and latitude (<var>Δy</var>) cells, in degrees.
         */
        private final float Δx, Δy;

        /**
         * Number of longitude (<var>nx</var>) and latitude (<var>ny</var>) values.
         */
        private final int nx, ny, nz;

        /**
         * Temporary buffer for ASCII characters, or {@code null} if the file is a binary file.
         */
        private final StringBuilder ascii;

        /**
         * The grid created by {@link #readGrid(FloatBuffer, Loader, URI)}.
         */
        LoadedGrid.Float<Angle,Angle> grid;

        /**
         * Creates a new reader for the given channel. The file can be binary or ASCII.
         * This constructor parses the header immediately, but does not read any grid.
         *
         * @param channel  where to read data from.
         * @param buffer   the buffer to use. That buffer must use little endian byte order
         *                 and have a capacity divisible by the size of the {@code float} type.
         * @param file     path to the longitude or latitude difference file. Used for parameter declaration and error reporting.
         */
        Loader(final ReadableByteChannel channel, final ByteBuffer buffer, final GridFile file)
                throws IOException, FactoryException
        {
            super(channel, buffer, file);
            // After the description we need (3 int + 5 float) = 32 bytes
            // for a binary header, or 74 characters for an ASCII header.
            ensureBufferContains(DESCRIPTION_LENGTH + 80);
            for (int i=0; i<NADCON.length(); i++) {
                if (buffer.get() != NADCON.charAt(i)) {
                    throw unexpectedFormat();
                }
            }
            if (isASCII(buffer)) {
                ascii = new StringBuilder();
                nx = Integer.parseInt(nextWord());
                ny = Integer.parseInt(nextWord());
                nz = Integer.parseInt(nextWord());
                x0 = Float.parseFloat(nextWord());
                Δx = Float.parseFloat(nextWord());
                y0 = Float.parseFloat(nextWord());
                Δy = Float.parseFloat(nextWord());
                     Float.parseFloat(nextWord());
            } else {
                ascii = null;
                buffer.position(DESCRIPTION_LENGTH);
                nx = buffer.getInt();     // Number of data elements in each record.
                ny = buffer.getInt();     // Number of records in the file.
                nz = buffer.getInt();     // Not used.
                x0 = buffer.getFloat();   // Longitude of first record (westmost).
                Δx = buffer.getFloat();   // Cell size in degrees of longitude.
                y0 = buffer.getFloat();   // Latitude of first record (southmost).
                Δy = buffer.getFloat();   // Cell size in degrees of latitude.
                                          // One more float at this position, which we ignore.
            }
            if (nx < 8 || ny < 1 || nz < 1 || !(Δx > 0) || !(Δy > 0) || Float.isNaN(x0) || Float.isNaN(y0)) {
                throw unexpectedFormat();
            }
            if (ascii == null) {
                skip((nx + 1) * Float.BYTES - buffer.position());
            }
        }

        /**
         * Returns {@code true} if all remaining characters in the buffer are US-ASCII characters for real numbers,
         * except the characters on the first line which may be any printable US-ASCII characters. If the content is
         * assumed ASCII, then the buffer position is set to the first EOL character.
         *
         * @return {@code true} if the file seems to be an ASCII one.
         */
        private static boolean isASCII(final ByteBuffer buffer) {
            int newLine = 0;
            while (buffer.hasRemaining()) {
                final char c = (char) buffer.get();
                if (c != ' ' && !(c >= '+' && c <= '9' && c != ',' && c != '/')) {          // (space) + - . [0-9]
                    if (c == '\r' || c == '\n') {
                        if (newLine == 0) {
                            newLine = buffer.position();
                        }
                    } else {
                        if (newLine == 0 && c >= 32 & c <= 126) {
                            continue;                   // Accept other US-ASCII characters ony on the first line.
                        }
                        return false;
                    }
                }
            }
            if (newLine == 0) {
                return false;          // If it was an ASCII file, we would have found at least one EOL character.
            }
            buffer.position(newLine);
            return true;
        }

        /**
         * Returns the next word from an ASCII file. This method is invoked only for parsing ASCII files,
         * in which case the {@link #ascii} string builder is non-null.
         */
        private String nextWord() throws IOException {
            char c;
            do {
                ensureBufferContains(1);
                c = (char) buffer.get();
            } while (Character.isWhitespace(c));
            ascii.setLength(0);
            do {
                ascii.append(c);
                ensureBufferContains(1);
                c = (char) buffer.get();
            } while (!Character.isWhitespace(c));
            return ascii.toString();
        }

        /**
         * Returns the exception to throw in the file does not seems to be a NADCON format.
         */
        private FactoryException unexpectedFormat() {
            return new FactoryException(Errors.format(Errors.Keys.UnexpectedFileFormat_2, NADCON, file));
        }

        /**
         * Loads latitude or longitude shifts data. This method should be invoked twice:
         *
         * <ol>
         *   <li>On an instance created for the latitude shifts file with a {@code latitude} argument set to null.</li>
         *   <li>On an instance created for the longitude shifts file with a {@code latitude} argument set to the
         *       instance created in the previous step.</li>
         * </ol>
         *
         * The result is stored in the {@link #grid} field.
         *
         * @param fb               a {@code FloatBuffer} view over the full {@link #buffer} range.
         * @param latitudeShifts   the previously loaded latitude shifts, or {@code null} if not yet loaded.
         * @param longitudeShifts  the file for the longitude grid.
         */
        @SuppressWarnings("lossy-conversions")      // Implicit cast from double to float in compound assignment.
        final void readGrid(final FloatBuffer fb, final Loader latitudeShifts, final GridFile longitudeShifts)
                throws IOException, FactoryException, NoninvertibleTransformException
        {
            final int dim;
            final double scale;
            if (latitudeShifts == null) {
                dim   = 1;                                              // Dimension of latitudes.
                scale = DEGREES_TO_SECONDS * Δy;                        // NADCON shifts are positive north.
                grid  = new LoadedGrid.Float<>(2, Units.DEGREE, Units.DEGREE,
                        true, x0, y0, Δx, Δy, nx, ny, PARAMETERS, file, longitudeShifts);
                grid.accuracy = SECOND_PRECISION / DEGREES_TO_SECONDS;
            } else {
                if (x0 != latitudeShifts.x0 || Δx != latitudeShifts.Δx || nx != latitudeShifts.nx ||
                    y0 != latitudeShifts.y0 || Δy != latitudeShifts.Δy || ny != latitudeShifts.ny || nz != latitudeShifts.nz)
                {
                    throw new FactoryException(Errors.format(Errors.Keys.MismatchedGridGeometry_2,
                            latitudeShifts.file.parameter.getPath(), file.parameter.getPath()));
                }
                dim   = 0;                                              // Dimension of longitudes
                scale = -DEGREES_TO_SECONDS * Δx;                       // NADCON shifts are positive west.
                grid  = latitudeShifts.grid;                            // Continue writing in existing grid.
            }
            final float[] array = grid.offsets[dim];
            if (ascii != null) {
                for (int i=0; i<array.length; i++) {
                    array[i] = (float) (Double.parseDouble(nextWord()) / scale);
                }
            } else {
                /*
                 * Transfer all data from the FloatBuffer to the float[] array, except one float at the beginning
                 * of every row which must be skipped. That skipped float value is not a translation value and is
                 * expected to be always zero.
                 */
                syncView(fb);
                int forCurrentRow = 0;
                for (int i=0; i<array.length;) {
                    if (forCurrentRow == 0) {
                        if (!fb.hasRemaining()) {
                            fillBuffer(fb);
                        }
                        if (fb.get() != 0) {
                            throw unexpectedFormat();
                        }
                        forCurrentRow = nx;
                    }
                    int remaining = fb.remaining();
                    if (remaining == 0) {
                        fillBuffer(fb);
                        remaining = fb.remaining();
                    }
                    final int n = Math.min(forCurrentRow, remaining);
                    fb.get(array, i, n);
                    forCurrentRow -= n;
                    i += n;
                }
                /*
                 * Convert seconds to degrees for consistency with the unit declared at the beginning of this method,
                 * then divide by cell size for consistency with the `isCellRatio = true` configuration.
                 */
                for (int i=0; i<array.length; i++) {
                    array[i] /= scale;
                }
            }
        }

        /**
         * Invoked when the given {@code FloatBuffer} buffer is empty. This method requests one {@code float}
         * from the channel, but the channel will usually give us as many data as the buffer can contain.
         */
        private void fillBuffer(final FloatBuffer fb) throws IOException {
            buffer.position(fb.position() * Float.BYTES).limit(fb.limit() * Float.BYTES);
            ensureBufferContains(Float.BYTES);  // Require at least one float, but usually get many.
            syncView(fb);
        }

        /**
         * Sets the position and limit of the given {@code FloatBuffer} to the same position and limit
         * than the underlying {@code ByteBuffer}, converted to units of {@code float} data type.
         */
        private void syncView(final FloatBuffer fb) {
            if ((buffer.position() % Float.BYTES) != 0) {
                buffer.compact();                               // For bytes alignment with FloatBuffer.
            }
            fb.limit(buffer.limit() / Float.BYTES).position(buffer.position() / Float.BYTES);
        }
    }
}
