/*
 * 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.coverage.grid;

import java.util.Arrays;
import org.opengis.util.FactoryException;
import org.opengis.referencing.datum.PixelInCell;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.TransformException;
import org.apache.sis.referencing.operation.builder.LinearTransformBuilder;
import org.apache.sis.referencing.operation.transform.LinearTransform;
import org.apache.sis.referencing.operation.transform.MathTransforms;
import org.apache.sis.geometry.GeneralEnvelope;
import org.apache.sis.geometry.Envelopes;
import org.apache.sis.util.ArgumentChecks;


/**
 * Method for replacing a non-linear "grid to CRS" conversion by a linear conversion (affine transform).
 * The {@code GridGeometry} class allows non-linear {@linkplain GridGeometry#getGridToCRS "grid to CRS"}
 * conversions, but some {@code GridGeometry} usages are restricted to linear (affine) conversions.
 * The {@code DomainLinearizer} class encapsulates the method used for replacing non-linear conversions
 * by a linear approximation.
 *
 * <p>The same instance can be reused by invoking {@code apply(…)} methods for many {@link GridCoverage}
 * or {@link GridGeometry} instances.</p>
 *
 * <h2>Limitations</h2>
 * Current implementation is designed for two-dimensional grid geometries.
 * Support for higher dimensions is not guaranteed.
 *
 * @author  Martin Desruisseaux (Geomatys)
 * @version 1.1
 *
 * @see LinearTransformBuilder#approximate(MathTransform, Envelope)
 *
 * @since 1.1
 */
public class DomainLinearizer {
    /**
     * Whether to force lower grid coordinates to (0,0,…).
     *
     * @see #getGridStartsAtZero()
     */
    private boolean gridStartsAtZero;

    /**
     * Scale factor applied on grid coordinates, or 1 if none.
     *
     * @see #getScaleFactor()
     */
    private double scale = 1;

    /**
     * The processor to use for coverage ressampling, created when first needed.
     *
     * @see #processor()
     */
    private GridCoverageProcessor processor;

    /**
     * Creates a new linearizer.
     */
    public DomainLinearizer() {
    }

    /**
     * Returns whether {@code GridExtent} should have their lower grid coordinates set to zero.
     * If {@code true}, then {@code DomainLinearizer} will opportunistically apply translation
     * on the {@linkplain GridGeometry#getGridToCRS "grid to CRS"} conversion in such a way that
     * {@link GridExtent#getLow()} is 0 for all dimensions.
     *
     * @return whether to force lower grid coordinates to (0,0,…).
     *
     * @see GridExtent#startsAtZero()
     */
    public boolean getGridStartsAtZero() {
        return gridStartsAtZero;
    }

    /**
     * Sets whether {@code GridExtent} should have their lower grid coordinates set to zero.
     * The default value is {@code false}.
     *
     * @param  force  whether to force lower grid coordinates to (0,0,…).
     */
    public void setGridStartsAtZero(final boolean force) {
        gridStartsAtZero = force;
    }

    /**
     * Returns the scale factor applied on coordinates in all dimensions.
     *
     * @return scale factor applied on coordinates in all dimensions, or 1 if none.
     */
    public double getScaleFactor() {
        return scale;
    }

    /**
     * Sets the scale factor to apply on coordinates in all dimensions.
     * Must be a value greater than zero. The default value is 1.
     *
     * @param  factor  scale factor applied on coordinates in all dimensions, or 1 if none.
     */
    public void setScaleFactor(final double factor) {
        ArgumentChecks.ensureStrictlyPositive("factor", factor);
        scale = factor;
    }

    /**
     * Returns the grid coverage processor associated to this linearizer.
     */
    private GridCoverageProcessor processor() {
        if (processor == null) {
            processor = new GridCoverageProcessor();
        }
        return processor;
    }

    /**
     * Returns a grid coverage with a linear approximation of the <cite>grid to CRS</cite> conversion.
     * The linear approximation is computed by {@link #apply(GridGeometry)}. If the <cite>grid to CRS</cite>
     * conversion of the given coverage is already linear, then this method returns {@code coverage}.
     *
     * @param  coverage  the grid coverage in which to make the <cite>grid to CRS</cite> conversion linear.
     * @return a grid coverage with a linear approximation of the <cite>grid to CRS</cite> conversion.
     * @throws TransformException if some cell coordinates cannot be computed.
     */
    public GridCoverage apply(final GridCoverage coverage) throws TransformException {
        ArgumentChecks.ensureNonNull("coverage", coverage);
        final GridGeometry gg = coverage.getGridGeometry();
        final GridGeometry linearized = apply(gg);
        if (gg.equals(linearized)) {
            return coverage;
        }
        return processor().resample(coverage, linearized);
    }

    /**
     * Creates a grid geometry with a linear approximation of the <cite>grid to CRS</cite> conversion.
     * The approximation is computed by <cite>Least Mean Squares</cite> method: the affine transform
     * coefficients are chosen in way making the average value of (<var>position</var> − <var>linear
     * approximation of position</var>)² as small as possible for all cells in given grid geometry.
     * If the <cite>grid to CRS</cite> conversion of the given grid geometry is already linear,
     * then this method returns {@code gg}.
     *
     * @param  gg  the grid geometry in which to make the <cite>grid to CRS</cite> conversion linear.
     * @return a grid geometry with a linear approximation of the <cite>grid to CRS</cite> conversion.
     * @throws TransformException if some cell coordinates cannot be computed.
     */
    public GridGeometry apply(final GridGeometry gg) throws TransformException {
        ArgumentChecks.ensureNonNull("gg", gg);
        if (gg.nonLinears != 0) try {
            MathTransform   gridToCRS   = gg.requireGridToCRS(true);
            GeneralEnvelope domain      = gg.extent.toEnvelope();
            MathTransform   approximate = modify(LinearTransformBuilder.approximate(gridToCRS, domain));
            MathTransform   gridToGrid  = MathTransforms.concatenate(gridToCRS, approximate.inverse());
            domain = Envelopes.transform(gridToGrid, domain);
            final int dimension = domain.getDimension();
            final long[] coordinates = new long[dimension * 2];
            final double[] shift = new double[dimension];
            for (int i=0; i<dimension; i++) {
                long low  = Math.round(domain.getMinimum(i));
                long high = Math.round(domain.getMaximum(i));
                high = Math.max(low, Math.decrementExact(high));
                if (gridStartsAtZero) {
                    high = Math.subtractExact(high, low);
                    shift[i] = low;
                } else {
                    coordinates[i] = low;
                }
                coordinates[i + dimension] = high;
            }
            approximate = MathTransforms.concatenate(MathTransforms.translation(shift), approximate);
            if (!approximate.equals(gridToCRS)) {
                return new GridGeometry(new GridExtent(gg.extent, coordinates), PixelInCell.CELL_CENTER,
                                        approximate, gg.envelope.getCoordinateReferenceSystem());
            }
        } catch (FactoryException e) {
            final Throwable cause = e.getCause();
            if (cause instanceof TransformException) {
                throw (TransformException) cause;
            }
            throw new TransformException(e.getMessage(), e);
        }
        return gg;
    }

    /**
     * Callback for custom modification of linear approximation. This method is invoked by {@link #apply(GridGeometry)}
     * after a linear "grid to CRS" approximation has been computed by the <cite>Least Mean Squares</cite> method.
     * Subclasses can override this method for example in order to scale the conversion by some arbitrary factor.
     *
     * <h4>Tip</h4>
     * Scales (if desired) should be applied <em>before</em> {@code gridToCRS}, i.e. on grid coordinates.
     * Scales applied after the transform (i.e. on "real world" coordinates) may give unexpected results
     * if the conversion contains a rotation.
     *
     * @param  gridToCRS  an approximation of the "grid to CRS" conversion computed by {@code DomainLinearizer}.
     * @return the approximation to use for creating a new {@link GridGeometry}. Should be linear.
     */
    private MathTransform modify(final LinearTransform gridToCRS) {
        final double[] factors = new double[gridToCRS.getTargetDimensions()];
        Arrays.fill(factors, scale);
        return MathTransforms.concatenate(MathTransforms.scale(factors), gridToCRS);
    }
}
