/*
 *    GeoTools - The Open Source Java GIS Toolkit
 *    http://geotools.org
 *
 *    (C) 2004-2008, Open Source Geospatial Foundation (OSGeo)
 *
 *    This library is free software; you can redistribute it and/or
 *    modify it under the terms of the GNU Lesser General Public
 *    License as published by the Free Software Foundation;
 *    version 2.1 of the License.
 *
 *    This library is distributed in the hope that it will be useful,
 *    but WITHOUT ANY WARRANTY; without even the implied warranty of
 *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 *    Lesser General Public License for more details.
 *
 *    This package contains documentation from OpenGIS specifications.
 *    OpenGIS consortium's work is fully acknowledged here.
 */
package org.geotools.referencing.cs;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import javax.measure.converter.ConversionException;
import javax.measure.converter.UnitConverter;
import javax.measure.unit.NonSI;
import javax.measure.unit.SI;
import javax.measure.unit.Unit;

import org.geotools.measure.Measure;
import org.geotools.referencing.AbstractIdentifiedObject;
import org.geotools.referencing.operation.matrix.GeneralMatrix;
import org.geotools.resources.Classes;
import org.geotools.resources.i18n.ErrorKeys;
import org.geotools.resources.i18n.Errors;
import org.geotools.resources.i18n.Vocabulary;
import org.geotools.util.Utilities;
import org.opengis.geometry.MismatchedDimensionException;
import org.opengis.referencing.cs.AxisDirection;
import org.opengis.referencing.cs.CoordinateSystem;
import org.opengis.referencing.cs.CoordinateSystemAxis;
import org.opengis.referencing.operation.Matrix;
import org.opengis.util.InternationalString;


/**
 * The set of coordinate system axes that spans a given coordinate space. A coordinate system (CS)
 * is derived from a set of (mathematical) rules for specifying how coordinates in a given space
 * are to be assigned to points. The coordinate values in a coordinate tuple shall be recorded in
 * the order in which the coordinate system axes are recorded, whenever those
 * coordinates use a coordinate reference system that uses this coordinate system.
 * <p>
 * This class is conceptually <cite>abstract</cite>, even if it is technically possible to
 * instantiate it. Typical applications should create instances of the most specific subclass with
 * {@code Default} prefix instead. An exception to this rule may occurs when it is not possible to
 * identify the exact type. For example it is not possible to infer the exact coordinate system from
 * <A HREF="http://geoapi.sourceforge.net/snapshot/javadoc/org/opengis/referencing/doc-files/WKT.html"><cite>Well
 * Known Text</cite></A> is some cases (e.g. in a {@code LOCAL_CS} element). In such exceptional
 * situation, a plain {@code AbstractCS} object may be instantiated.
 *
 * @since 2.1
 *
 * @source $URL: http://svn.osgeo.org/geotools/branches/2.7.x/modules/library/referencing/src/main/java/org/geotools/referencing/cs/AbstractCS.java $
 * @version $Id: AbstractCS.java 37299 2011-05-25 05:21:24Z mbedward $
 * @author Martin Desruisseaux (IRD)
 *
 * @see DefaultCoordinateSystemAxis
 * @see javax.measure.unit.Unit
 * @see org.geotools.referencing.datum.AbstractDatum
 * @see org.geotools.referencing.crs.AbstractCRS
 */
public class AbstractCS extends AbstractIdentifiedObject implements CoordinateSystem {
    /**
     * Serial number for interoperability with different versions.
     */
    private static final long serialVersionUID = 6757665252533744744L;

    /**
     * Base axis to use for checking directions. This is used in order to trap
     * inconsistency like an axis named "Northing" with South direction.
     */
    private static final DefaultCoordinateSystemAxis[] DIRECTION_CHECKS = {
        DefaultCoordinateSystemAxis.NORTHING,
        DefaultCoordinateSystemAxis.EASTING,
        DefaultCoordinateSystemAxis.SOUTHING,
        DefaultCoordinateSystemAxis.WESTING
    };

    /**
     * The axis for this coordinate system at the specified dimension.
     */
    private final CoordinateSystemAxis[] axis;

    /**
     * The unit for measuring distance in this coordinate system, or {@code null} if none.
     * Will be computed only when first needed.
     */
    private transient Unit<?> distanceUnit;

    /**
     * Constructs a coordinate system from a name.
     *
     * @param name  The coordinate system name.
     * @param axis  The set of axis.
     */
    public AbstractCS(final String name, final CoordinateSystemAxis[] axis) {
        this(Collections.singletonMap(NAME_KEY, name), axis);
    }

    /**
     * Constructs a coordinate system from a set of properties. The properties map is given
     * unchanged to the {@linkplain AbstractIdentifiedObject#AbstractIdentifiedObject(Map)
     * super-class constructor}.
     *
     * @param properties   Set of properties. Should contains at least {@code "name"}.
     * @param axis         The set of axis.
     */
    public AbstractCS(final Map<String,?> properties, final CoordinateSystemAxis[] axis) {
        super(properties);
        ensureNonNull("axis", axis);
        this.axis = axis.clone();
        for (int i=0; i<axis.length; i++) {
            ensureNonNull("axis", axis, i);
            final AxisDirection direction = axis[i].getDirection();
            ensureNonNull("direction", direction);
            /*
             * Ensures that axis direction and units are compatible with the
             * coordinate system to be created. For example CartesianCS will
             * accepts only linear or dimensionless units.
             */
            if (!isCompatibleDirection(direction)) {
                // TOOD: localize name()
                throw new IllegalArgumentException(Errors.format(
                        ErrorKeys.ILLEGAL_AXIS_ORIENTATION_$2, direction.name(), getClass()));
            }
            final Unit<?> unit = axis[i].getUnit();
            ensureNonNull("unit", unit);
            if (!isCompatibleUnit(direction, unit)) {
                throw new IllegalArgumentException(Errors.format(
                            ErrorKeys.INCOMPATIBLE_UNIT_$1, unit));
            }
            /*
             * Ensures there is no axis along the same direction
             * (e.g. two North axis, or an East and a West axis).
             */
            final AxisDirection check = direction.absolute();
            if (!check.equals(AxisDirection.OTHER)) {
                for (int j=i; --j>=0;) {
                    if (check.equals(axis[j].getDirection().absolute())) {
                        // TODO: localize name()
                        final String nameI = axis[i].getDirection().name();
                        final String nameJ = axis[j].getDirection().name();
                        throw new IllegalArgumentException(Errors.format(
                                    ErrorKeys.COLINEAR_AXIS_$2, nameI, nameJ));
                    }
                }
            }
            /*
             * Checks for some inconsistency in naming and direction. For example if the axis
             * is named "Northing", then the direction must be North. Exceptions to this rule
             * are the directions along a meridian from a pole. For example a "Northing" axis
             * may have a "South along 180 deg" direction.
             */
            final String name = axis[i].getName().getCode();
            for (int j=0; j<DIRECTION_CHECKS.length; j++) {
                final DefaultCoordinateSystemAxis candidate = DIRECTION_CHECKS[j];
                if (candidate.nameMatches(name)) {
                    final AxisDirection expected = candidate.getDirection();
                    if (!direction.equals(expected)) {
                        DirectionAlongMeridian m = DirectionAlongMeridian.parse(direction);
                        /*
                         * Note: for the check below, maybe it would have be nice to use:
                         *
                         *     if (m == null || m.baseDirection.equals(expected.opposite())
                         *
                         * but the EPSG database contains many axis named "Northing" with
                         * direction like "South along 180 deg", so it doesn't seem to be
                         * considered as a contradiction...
                         */
                        if (m == null) {
                            throw new IllegalArgumentException(Errors.format(
                                    ErrorKeys.INCONSISTENT_AXIS_ORIENTATION_$2,
                                    name, direction.name()));
                        }
                    }
                }
            }
        }
    }

    /**
     * Creates a name for the predefined constants in subclasses. The name is an unlocalized String
     * object. However, since this method is used for creation of convenience objects only (not for
     * objects created from an "official" database), the "unlocalized" name is actually choosen
     * according the user's locale at class initialization time. The same name is also added in
     * a localizable form as an alias. Since the {@link #nameMatches} convenience method checks
     * the alias, it still possible to consider two objects are equivalent even if their names
     * were formatted in different locales.
     */
    static Map<String,Object> name(final int key) {
        final Map<String,Object> properties = new HashMap<String,Object>(4);
        final InternationalString name = Vocabulary.formatInternational(key);
        properties.put(NAME_KEY,  name.toString());
        properties.put(ALIAS_KEY, name);
        return properties;
    }

    /**
     * Returns {@code true} if the specified axis direction is allowed for this coordinate
     * system. This method is invoked at construction time for checking argument validity.
     * The default implementation returns {@code true} for all axis directions. Subclasses
     * will overrides this method in order to put more restrictions on allowed axis directions.
     *
     * @param direction The direction to test for compatibility.
     * @return {@code true} if the given direction is compatible with this coordinate system.
     */
    protected boolean isCompatibleDirection(final AxisDirection direction) {
        return true;
    }

    /**
     * Returns {@code true} is the specified unit is legal for the specified axis direction.
     * This method is invoked at construction time for checking units compatibility. The default
     * implementation returns {@code true} in all cases. Subclasses can override this method and
     * check for compatibility with {@linkplain SI#METER meter} or
     * {@linkplain NonSI#DEGREE_ANGLE degree} units.
     *
     * @param direction The direction of the axis having the given unit.
     * @param unit The unit to test for compatibility.
     * @return {@code true} if the given unit is compatible with this coordinate system.
     *
     * @since 2.2
     */
    protected boolean isCompatibleUnit(final AxisDirection direction, final Unit<?> unit) {
        return true;
    }

    /**
     * Returns the dimension of the coordinate system.
     * This is the number of axis.
     */
    public int getDimension() {
        return axis.length;
    }

    /**
     * Returns the axis for this coordinate system at the specified dimension.
     *
     * @param  dimension The zero based index of axis.
     * @return The axis at the specified dimension.
     * @throws IndexOutOfBoundsException if {@code dimension} is out of bounds.
     */
    public CoordinateSystemAxis getAxis(final int dimension) throws IndexOutOfBoundsException {
        return axis[dimension];
    }

    /**
     * Returns the axis direction for the specified coordinate system.
     *
     * @param  cs The coordinate system.
     * @return The axis directions for the specified coordinate system.
     */
    private static AxisDirection[] getAxisDirections(final CoordinateSystem cs) {
        final AxisDirection[] axis = new AxisDirection[cs.getDimension()];
        for (int i=0; i<axis.length; i++) {
            axis[i] = cs.getAxis(i).getDirection();
        }
        return axis;
    }

    /**
     * Returns an affine transform between two coordinate systems. Only units and
     * axis order (e.g. transforming from
     * ({@linkplain AxisDirection#NORTH NORTH},{@linkplain AxisDirection#WEST WEST}) to
     * ({@linkplain AxisDirection#EAST EAST},{@linkplain AxisDirection#NORTH NORTH}
     * are taken in account.
     * <p>
     * <b>Example:</b> If coordinates in {@code sourceCS} are (<var>x</var>,<var>y</var>) pairs
     * in metres and coordinates in {@code targetCS} are (-<var>y</var>,<var>x</var>) pairs in
     * centimetres, then the transformation can be performed as below:
     *
     * <pre><blockquote>
     *          [-y(cm)]   [ 0  -100    0 ] [x(m)]
     *          [ x(cm)] = [ 100   0    0 ] [y(m)]
     *          [ 1    ]   [ 0     0    1 ] [1   ]
     * </blockquote></pre>
     *
     * @param  sourceCS The source coordinate system.
     * @param  targetCS The target coordinate system.
     * @return The conversion from {@code sourceCS} to {@code targetCS} as
     *         an affine transform. Only axis direction and units are taken in account.
     * @throws IllegalArgumentException if axis doesn't matches, or the CS doesn't have the
     *         same geometry.
     * @throws ConversionException if the unit conversion is non-linear.
     */
    public static Matrix swapAndScaleAxis(final CoordinateSystem sourceCS,
                                          final CoordinateSystem targetCS)
            throws IllegalArgumentException, ConversionException
    {
        if (!Classes.sameInterfaces(sourceCS.getClass(), targetCS.getClass(), CoordinateSystem.class)) {
            throw new IllegalArgumentException(Errors.format(
                      ErrorKeys.INCOMPATIBLE_COORDINATE_SYSTEM_TYPE));
        }
        final AxisDirection[] sourceAxis = getAxisDirections(sourceCS);
        final AxisDirection[] targetAxis = getAxisDirections(targetCS);
        final GeneralMatrix matrix = new GeneralMatrix(sourceAxis, targetAxis);
        assert Arrays.equals(sourceAxis, targetAxis) == matrix.isIdentity() : matrix;
        /*
         * The previous code computed a matrix for swapping axis. Usually, this
         * matrix contains only 0 and 1 values with only one "1" value by row.
         * For example, the matrix operation for swapping x and y axis is:
         *
         *          [y]   [ 0  1  0 ] [x]
         *          [x] = [ 1  0  0 ] [y]
         *          [1]   [ 0  0  1 ] [1]
         *
         * Now, take in account units conversions. Each matrix's element (j,i)
         * is multiplied by the conversion factor from sourceCS.getUnit(i) to
         * targetCS.getUnit(j). This is an element-by-element multiplication,
         * not a matrix multiplication. The last column is processed in a special
         * way, since it contains the offset values.
         */
        final int sourceDim = matrix.getNumCol()-1;
        final int targetDim = matrix.getNumRow()-1;
        assert sourceDim == sourceCS.getDimension() : sourceCS;
        assert targetDim == targetCS.getDimension() : targetCS;
        for (int j=0; j<targetDim; j++) {
            final Unit<?> targetUnit = targetCS.getAxis(j).getUnit();
            for (int i=0; i<sourceDim; i++) {
                final double element = matrix.getElement(j,i);
                if (element == 0) {
                    // There is no dependency between source[i] and target[j]
                    // (i.e. axis are orthogonal).
                    continue;
                }
                final Unit<?> sourceUnit = sourceCS.getAxis(i).getUnit();
                if (Utilities.equals(sourceUnit, targetUnit)) {
                    // There is no units conversion to apply
                    // between source[i] and target[j].
                    continue;
                }
                final UnitConverter converter = sourceUnit.getConverterTo(targetUnit);
                if (!converter.isLinear()) {
                    throw new ConversionException(Errors.format(
                              ErrorKeys.NON_LINEAR_UNIT_CONVERSION_$2, sourceUnit, targetUnit));
                }
                final double offset = converter.convert(0);
// JSR-275      final double scale  = converter.derivative(0);
                final double scale  = converter.convert(1) - offset;
                matrix.setElement(j,i, element*scale);
                matrix.setElement(j,sourceDim, matrix.getElement(j,sourceDim) + element*offset);
            }
        }
        return matrix;
    }

    /**
     * Returns a coordinate system with "standard" axis order and units.
     * Most of the time, this method returns one of the predefined constants with axis in
     * (<var>longitude</var>,<var>latitude</var>) or (<var>X</var>,<var>Y</var>) order,
     * and units in degrees or metres. In some particular cases like
     * {@linkplain org.opengis.referencing.cs.CartesianCS Cartesian CS}, this method may
     * create a new instance on the fly. In every cases this method attempts to return a
     * <A HREF="http://en.wikipedia.org/wiki/Right_hand_rule">right-handed</A> coordinate
     * system, but this is not garanteed.
     * <p>
     * This method is typically used together with {@link #swapAndScaleAxis swapAndScaleAxis}
     * for the creation of a transformation step before some
     * {@linkplain org.opengis.referencing.operation.MathTransform math transform}.
     * Example:
     *
     * <blockquote><pre>
     * Matrix step1 = swapAndScaleAxis(sourceCS, standard(sourceCS));
     * Matrix step2 = ... some transform operating on standard axis ...
     * Matrix step3 = swapAndScaleAxis(standard(targetCS), targetCS);
     * </pre></blockquote>
     *
     * A rational for standard axis order and units is explained in the <cite>Axis units and
     * direction</cite> section in the {@linkplain org.geotools.referencing.operation.projection
     * description of map projection package}.
     *
     * @param  cs The coordinate system.
     * @return A constant similar to the specified {@code cs} with "standard" axis.
     * @throws IllegalArgumentException if the specified coordinate system is unknow to this method.
     *
     * @since 2.2
     */
    public static CoordinateSystem standard(final CoordinateSystem cs)
            throws IllegalArgumentException
    {
        return PredefinedCS.standard(cs);
    }

    /**
     * Suggests an unit for measuring distances in this coordinate system. The default
     * implementation scans all {@linkplain CoordinateSystemAxis#getUnit axis units},
     * ignoring angular ones (this also implies ignoring {@linkplain Unit#ONE dimensionless} ones).
     * If more than one non-angular unit is found, the default implementation returns the "largest"
     * one (e.g. kilometers instead of meters).
     *
     * @return Suggested distance unit.
     * @throws ConversionException if some non-angular units are incompatibles.
     */
    final Unit<?> getDistanceUnit() throws ConversionException {
        Unit<?> unit = distanceUnit;  // Avoid the need for synchronization.
        if (unit == null) {
            for (int i=0; i<axis.length; i++) {
                final Unit<?> candidate = axis[i].getUnit();
                if (candidate!=null && !candidate.isCompatible(SI.RADIAN)) {
                    // TODO: checks the unit scale type (keeps RATIO only).
                    if (unit != null) {
                        final UnitConverter converter = candidate.getConverterTo(unit);
                        if (!converter.isLinear()) {
                            // TODO: use the localization provided in 'swapAxis'. We could also
                            //       do a more intelligent work by checking the unit scale type.
                            throw new ConversionException("Unit conversion is non-linear");
                        }
// JSR-275              final double scale = converter.derivative(0);
                        final double scale = converter.convert(1) - converter.convert(0);
                        if (Math.abs(scale) <= 1) {
                            // The candidate is a "smaller" unit than the current one
                            // (e.g. "m" instead of "km"). Keeps the "largest" unit.
                            continue;
                        }
                    }
                    unit = candidate;
                }
            }
            distanceUnit = unit;
        }
        return unit;
    }

    /**
     * Convenience method for checking object dimension validity.
     *
     * @param  name The name of the argument to check.
     * @param  coordinates The coordinate array to check.
     * @throws MismatchedDimensionException if the coordinate doesn't have the expected dimension.
     */
    final void ensureDimensionMatch(final String name, final double[] coordinates)
            throws MismatchedDimensionException
    {
        if (coordinates.length != axis.length) {
            throw new MismatchedDimensionException(Errors.format(
                        ErrorKeys.MISMATCHED_DIMENSION_$3, name, coordinates.length, axis.length));
        }
    }

    /**
     * Computes the distance between two points. This method is not available for all coordinate
     * systems. For example, {@linkplain DefaultEllipsoidalCS ellipsoidal CS} doesn't have
     * suffisient information.
     *
     * @param  coord1 Coordinates of the first point.
     * @param  coord2 Coordinates of the second point.
     * @return The distance between {@code coord1} and {@code coord2}.
     * @throws UnsupportedOperationException if this coordinate system can't compute distances.
     * @throws MismatchedDimensionException if a coordinate doesn't have the expected dimension.
     *
     * @todo Provides a localized message in the exception.
     */
    public Measure distance(final double[] coord1, final double[] coord2)
            throws UnsupportedOperationException, MismatchedDimensionException
    {
        throw new UnsupportedOperationException();
    }

    /**
     * Returns every axis from the specified coordinate system as instance of
     * {@link DefaultCoordinateSystemAxis}. This allow usage of some methods
     * specific to that implementation.
     */
    private static DefaultCoordinateSystemAxis[] getDefaultAxis(final CoordinateSystem cs) {
        final DefaultCoordinateSystemAxis[] axis = new DefaultCoordinateSystemAxis[cs.getDimension()];
        for (int i=0; i<axis.length; i++) {
            final CoordinateSystemAxis a = cs.getAxis(i);
            DefaultCoordinateSystemAxis c = DefaultCoordinateSystemAxis.getPredefined(a);
            if (c == null) {
                if (a instanceof DefaultCoordinateSystemAxis) {
                    c = (DefaultCoordinateSystemAxis) a;
                } else {
                    c = new DefaultCoordinateSystemAxis(a);
                }
            }
            axis[i] = c;
        }
        return axis;
    }

    /**
     * Returns {@code true} if every axis in the specified {@code userCS} are colinear with axis
     * in this coordinate system. The comparaison is insensitive to axis order and units. What
     * matter is axis names (because they are fixed by ISO 19111 specification) and directions.
     * <p>
     * If this method returns {@code true}, then there is good chances that this CS can be used
     * together with {@code userCS} as arguments to {@link #swapAndScaleAxis swapAndScaleAxis}.
     * <p>
     * This method should not be public because current implementation is not fully consistent
     * for every pair of CS. It tries to check the opposite direction in addition of the usual
     * one, but only a few pre-defined axis declare their opposite. This method should be okay
     * when invoked on pre-defined CS declared in this package. {@link PredefinedCS} uses this
     * method only that way.
     */
    final boolean axisColinearWith(final CoordinateSystem userCS) {
        if (userCS.getDimension() != getDimension()) {
            return false;
        }
        final DefaultCoordinateSystemAxis[] axis0 = getDefaultAxis(this);
        final DefaultCoordinateSystemAxis[] axis1 = getDefaultAxis(userCS);
next:   for (int i=0; i<axis0.length; i++) {
            final DefaultCoordinateSystemAxis direct   = axis0[i];
            final DefaultCoordinateSystemAxis opposite = direct.getOpposite();
            for (int j=0; j<axis1.length; j++) {
                final DefaultCoordinateSystemAxis candidate = axis1[j];
                if (candidate != null) {
                    if (candidate.equals(direct,   false, false) || (opposite != null &&
                        candidate.equals(opposite, false, false)))
                    {
                        axis1[j] = null; // Flags as already compared.
                        continue next;
                    }
                }
            }
            return false;
        }
        assert directionColinearWith(userCS);
        return true;
    }

    /**
     * Compares directions only, without consideration for the axis name.
     */
    final boolean directionColinearWith(final CoordinateSystem userCS) {
        if (userCS.getDimension() != axis.length) {
            return false;
        }
        final AxisDirection[] checks = new AxisDirection[axis.length];
        for (int i=0; i<checks.length; i++) {
            checks[i] = userCS.getAxis(i).getDirection().absolute();
        }
next:   for (int i=0; i<axis.length; i++) {
            final AxisDirection direction = axis[i].getDirection().absolute();
            for (int j=0; j<checks.length; j++) {
                final AxisDirection candidate = checks[j];
                if (candidate != null && candidate.equals(direction)) {
                    checks[j] = null;  // Flags as already compared.
                    continue next;
                }
            }
            return false;
        }
        return true;
    }

    /**
     * Compares the specified object with this coordinate system for equality.
     *
     * @param  object The object to compare to {@code this}.
     * @param  compareMetadata {@code true} for performing a strict comparaison, or
     *         {@code false} for comparing only properties relevant to transformations.
     * @return {@code true} if both objects are equal.
     */
    @Override
    public boolean equals(final AbstractIdentifiedObject object, final boolean compareMetadata) {
        if (object == this) {
            return true; // Slight optimization.
        }
        if (super.equals(object, compareMetadata)) {
            final AbstractCS that = (AbstractCS) object;
            return equals(this.axis, that.axis, compareMetadata);
        }
        return false;
    }

    /**
     * Returns a hash value for this coordinate system.
     *
     * @return The hash code value. This value doesn't need to be the same
     *         in past or future versions of this class.
     */
    @Override
    public int hashCode() {
        int code = (int)serialVersionUID;
        for (int i=0; i<axis.length; i++) {
            code = code*37 + axis[i].hashCode();
        }
        return code;
    }
}
