/*
 * 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;

import java.util.Map;
import java.util.HashMap;
import java.util.Collection;
import javax.measure.Unit;
import javax.measure.quantity.Angle;
import org.opengis.annotation.UML;
import org.opengis.annotation.Specification;
import org.opengis.metadata.Identifier;
import org.opengis.metadata.citation.Citation;
import org.opengis.parameter.ParameterDescriptorGroup;
import org.opengis.parameter.GeneralParameterDescriptor;
import org.opengis.referencing.cs.*;
import org.opengis.referencing.crs.*;
import org.opengis.referencing.IdentifiedObject;
import org.opengis.referencing.datum.Datum;
import org.opengis.referencing.datum.Ellipsoid;
import org.opengis.referencing.datum.PrimeMeridian;
import org.opengis.referencing.datum.GeodeticDatum;
import org.opengis.referencing.datum.VerticalDatum;
import org.opengis.referencing.datum.VerticalDatumType;
import org.apache.sis.util.Static;
import org.apache.sis.util.Utilities;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.resources.Vocabulary;
import org.apache.sis.referencing.CommonCRS;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.referencing.AbstractIdentifiedObject;
import org.apache.sis.referencing.datum.DefaultPrimeMeridian;
import org.apache.sis.referencing.crs.DefaultGeographicCRS;
import org.apache.sis.referencing.cs.AxesConvention;
import org.apache.sis.referencing.cs.DefaultEllipsoidalCS;
import org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory.Context;

import static java.util.Collections.singletonMap;


/**
 * A set of static methods working on GeoAPI referencing objects.
 * Some of those methods may be useful, but not really rigorous.
 * This is why they do not appear in the public packages.
 *
 * <p><strong>Do not rely on this API!</strong> It may change in incompatible way in any future release.</p>
 *
 * @author  Martin Desruisseaux (IRD, Geomatys)
 * @version 1.0
 * @since   0.5
 * @module
 */
public final class ReferencingUtilities extends Static {
    /**
     * Do not allow instantiation of this class.
     */
    private ReferencingUtilities() {
    }

    /**
     * Returns the longitude value relative to the Greenwich Meridian, expressed in the specified units.
     * This method provides the same functionality than {@link DefaultPrimeMeridian#getGreenwichLongitude(Unit)},
     * but on arbitrary implementation.
     *
     * @param  primeMeridian  the prime meridian from which to get the Greenwich longitude, or {@code null}.
     * @param  unit           the unit for the prime meridian to return.
     * @return the prime meridian in the given units, or {@code 0} if the given prime meridian was null.
     *
     * @see DefaultPrimeMeridian#getGreenwichLongitude(Unit)
     * @see org.apache.sis.referencing.CRS#getGreenwichLongitude(GeodeticCRS)
     */
    public static double getGreenwichLongitude(final PrimeMeridian primeMeridian, final Unit<Angle> unit) {
        if (primeMeridian == null) {
            return 0;
        } else if (primeMeridian instanceof DefaultPrimeMeridian) {         // Maybe the user overrode some methods.
            return ((DefaultPrimeMeridian) primeMeridian).getGreenwichLongitude(unit);
        } else {
            return primeMeridian.getAngularUnit().getConverterTo(unit).convert(primeMeridian.getGreenwichLongitude());
        }
    }

    /**
     * Returns the unit used for all axes in the given coordinate system.
     * If not all axes use the same unit, then this method returns {@code null}.
     *
     * <p>This method is used either when the coordinate system is expected to contain exactly one axis,
     * or for operations that support only one units for all axes, for example Well Know Text version 1
     * (WKT 1) formatting.</p>
     *
     * @param  cs  the coordinate system for which to get the unit, or {@code null}.
     * @return the unit for all axis in the given coordinate system, or {@code null}.
     *
     * @see org.apache.sis.internal.referencing.AxisDirections#getAngularUnit(CoordinateSystem, Unit)
     */
    public static Unit<?> getUnit(final CoordinateSystem cs) {
        Unit<?> unit = null;
        if (cs != null) {
            for (int i=cs.getDimension(); --i>=0;) {
                final CoordinateSystemAxis axis = cs.getAxis(i);
                if (axis != null) {                                         // Paranoiac check.
                    final Unit<?> candidate = axis.getUnit();
                    if (candidate != null) {
                        if (unit == null) {
                            unit = candidate;
                        } else if (!unit.equals(candidate)) {
                            return null;
                        }
                    }
                }
            }
        }
        return unit;
    }

    /**
     * Returns the number of dimensions of the given CRS, or 0 if {@code null}.
     *
     * @param  crs  the CRS from which to get the number of dimensions, or {@code null}.
     * @return the number of dimensions, or 0 if the given CRS or its coordinate system is null.
     */
    public static int getDimension(final CoordinateReferenceSystem crs) {
        if (crs != null) {
            final CoordinateSystem cs = crs.getCoordinateSystem();
            if (cs != null) {                                               // Paranoiac check.
                return cs.getDimension();
            }
        }
        return 0;
    }

    /**
     * Returns the GeoAPI interface implemented by the given object, or the implementation class
     * if the interface is unknown.
     *
     * @param  object  the object for which to get the GeoAPI interface, or {@code null}.
     * @return GeoAPI interface or implementation class of the given object, or {@code null} if the given object is null.
     */
    public static Class<?> getInterface(final IdentifiedObject object) {
        if (object == null) {
            return null;
        } else if (object instanceof AbstractIdentifiedObject) {
             return ((AbstractIdentifiedObject) object).getInterface();
        } else {
             return object.getClass();
        }
    }

    /**
     * Copies all {@link SingleCRS} components from the given source to the given collection.
     * For each {@link CompoundCRS} element found in the iteration, this method replaces the
     * {@code CompoundCRS} by its {@linkplain CompoundCRS#getComponents() components}, which
     * may themselves have other {@code CompoundCRS}. Those replacements are performed recursively
     * until we obtain a flat view of CRS components.
     *
     * @param  source  the collection of single or compound CRS.
     * @param  addTo   where to add the single CRS in order to obtain a flat view of {@code source}.
     * @return {@code true} if this method found only single CRS in {@code source}, in which case {@code addTo}
     *         got the same content (assuming that {@code addTo} was empty prior this method call).
     * @throws ClassCastException if a CRS is neither a {@link SingleCRS} or a {@link CompoundCRS}.
     *
     * @see org.apache.sis.referencing.CRS#getSingleComponents(CoordinateReferenceSystem)
     */
    public static boolean getSingleComponents(final Iterable<? extends CoordinateReferenceSystem> source,
            final Collection<? super SingleCRS> addTo) throws ClassCastException
    {
        boolean sameContent = true;
        for (final CoordinateReferenceSystem candidate : source) {
            if (candidate instanceof CompoundCRS) {
                getSingleComponents(((CompoundCRS) candidate).getComponents(), addTo);
                sameContent = false;
            } else {
                // Intentional CassCastException here if the candidate is not a SingleCRS.
                addTo.add((SingleCRS) candidate);
            }
        }
        return sameContent;
    }

    /**
     * Returns {@code true} if the type of the given datum is ellipsoidal. A vertical datum is not allowed
     * to be ellipsoidal according ISO 19111, but Apache SIS relaxes this restriction in some limited cases,
     * for example when parsing a string in the legacy WKT 1 format. Apache SIS should not expose those
     * vertical heights as much as possible, and instead try to combine them with three-dimensional
     * geographic or projected CRS as soon as it can.
     *
     * @param  datum  the datum to test, or {@code null} if none.
     * @return {@code true} if the given datum is non null and of ellipsoidal type.
     *
     * @see org.apache.sis.internal.referencing.VerticalDatumTypes#ELLIPSOIDAL
     */
    public static boolean isEllipsoidalHeight(final VerticalDatum datum) {
        if (datum != null) {
            final VerticalDatumType type = datum.getVerticalDatumType();
            if (type != null) {
                return "ELLIPSOIDAL".equalsIgnoreCase(type.name());
            }
        }
        return false;
    }

    /**
     * Returns the ellipsoid used by the given coordinate reference system, or {@code null} if none.
     * More specifically:
     *
     * <ul>
     *   <li>If the given CRS is an instance of {@link SingleCRS} and its datum is a {@link GeodeticDatum},
     *       then this method returns the datum ellipsoid.</li>
     *   <li>Otherwise if the given CRS is an instance of {@link CompoundCRS}, then this method
     *       invokes itself recursively for each component until a geodetic datum is found.</li>
     *   <li>Otherwise this method returns {@code null}.</li>
     * </ul>
     *
     * Note that this method does not check if there is more than one ellipsoid (it should never be the case).
     *
     * @param  crs  the coordinate reference system for which to get the ellipsoid.
     * @return the ellipsoid, or {@code null} if none.
     */
    public static Ellipsoid getEllipsoid(final CoordinateReferenceSystem crs) {
        if (crs != null) {
            if (crs instanceof SingleCRS) {
                final Datum datum = ((SingleCRS) crs).getDatum();
                if (datum instanceof GeodeticDatum) {
                    final Ellipsoid e = ((GeodeticDatum) datum).getEllipsoid();
                    if (e != null) return e;
                }
            }
            if (crs instanceof CompoundCRS) {
                for (final CoordinateReferenceSystem c : ((CompoundCRS) crs).getComponents()) {
                    final Ellipsoid e = getEllipsoid(c);
                    if (e != null) return e;
                }
            }
        }
        return null;
    }

    /**
     * Returns the ellipsoid used by the specified coordinate reference system, provided that the two first dimensions
     * use an instance of {@link GeographicCRS}. Otherwise (i.e. if the two first dimensions are not geographic),
     * returns {@code null}.
     *
     * <p>This method excludes geocentric CRS on intent. Some callers needs this exclusion as a way to identify
     * which CRS in a Geographic/Geocentric conversion is the geographic one. An other point of view is to said
     * that if this method returns a non-null value, then the coordinates are expected to be either two-dimensional
     * or three-dimensional with an ellipsoidal height.</p>
     *
     * @param  crs  the coordinate reference system for which to get the ellipsoid.
     * @return the ellipsoid in the given CRS, or {@code null} if none.
     */
    public static Ellipsoid getEllipsoidOfGeographicCRS(CoordinateReferenceSystem crs) {
        while (!(crs instanceof GeodeticCRS)) {
            if (crs instanceof CompoundCRS) {
                crs = ((CompoundCRS) crs).getComponents().get(0);
            } else {
                return null;
            }
        }
        /*
         * In order to determine if the CRS is geographic, checking the CoordinateSystem type is more reliable
         * then checking if the CRS implements the GeographicCRS interface.  This is because the GeographicCRS
         * interface is GeoAPI-specific, so a CRS may be OGC-compliant without implementing that interface.
         */
        if (crs.getCoordinateSystem() instanceof EllipsoidalCS) {
            return ((GeodeticCRS) crs).getDatum().getEllipsoid();
        } else {
            return null;    // Geocentric CRS.
        }
    }

    /**
     * Derives a geographic CRS with (<var>longitude</var>, <var>latitude</var>) axis in the specified order and in decimal degrees.
     * If no such CRS can be obtained or created, returns {@code null}.
     *
     * <p>This method does not set the prime meridian to Greenwich.
     * Meridian rotation, if needed, shall be performed by the caller.</p>
     *
     * @param  crs      a source CRS, or {@code null}.
     * @param  latlon   {@code true} for (latitude, longitude) axis order, or {@code false} for (longitude, latitude).
     * @param  allow3D  whether this method is allowed to return three-dimensional CRS (with ellipsoidal height).
     * @return a two-dimensional geographic CRS with standard axes, or {@code null} if none.
     */
    public static GeographicCRS toNormalizedGeographicCRS(CoordinateReferenceSystem crs, final boolean latlon, final boolean allow3D) {
        /*
         * ProjectedCRS instances always have a GeographicCRS as their base.
         * More generally, derived CRS are always derived from a base, which
         * is often (but not necessarily) geographic.
         */
        while (crs instanceof GeneralDerivedCRS) {
            crs = ((GeneralDerivedCRS) crs).getBaseCRS();
        }
        if (crs instanceof GeodeticCRS) {
            /*
             * At this point we usually have a GeographicCRS, but it could also be a GeocentricCRS.
             * If we can let `forConvention` do its job, do that first since it may return a cached
             * instance. If the CRS is a `GeographicCRS` but not a `DefaultGeographicCRS`, create a
             * CRS in this code instead than invoking `DefaultGeographicCRS.castOrCopy(…)` in order
             * to create only one CRS instead of two.
             */
            final CoordinateSystem cs = crs.getCoordinateSystem();
            if (!latlon && crs instanceof DefaultGeographicCRS && (allow3D || cs.getDimension() == 2)) {
                return ((DefaultGeographicCRS) crs).forConvention(AxesConvention.NORMALIZED);
            }
            /*
             * Get a normalized coordinate system with the number of dimensions authorized by the
             * `allow3D` argument. We do not check if we can invoke `cs.forConvention(…)` because
             * it is unlikely that `cs` will be an instance of `DefaultEllipsoidalCS` is `crs` is
             * not a `DefaultGeographicCRS`. The code below has more chances to use cached instance.
             */
            EllipsoidalCS normalizedCS;
            if (allow3D && cs.getDimension() >= 3) {
                normalizedCS = CommonCRS.WGS84.geographic3D().getCoordinateSystem();
                if (!latlon) {
                    normalizedCS = DefaultEllipsoidalCS.castOrCopy(normalizedCS).forConvention(AxesConvention.NORMALIZED);
                }
            } else if (latlon) {
                normalizedCS = CommonCRS.WGS84.geographic().getCoordinateSystem();
            } else {
                normalizedCS = CommonCRS.defaultGeographic().getCoordinateSystem();
            }
            if (crs instanceof GeographicCRS && Utilities.equalsIgnoreMetadata(normalizedCS, cs)) {
                return (GeographicCRS) crs;
            }
            return new DefaultGeographicCRS(
                    singletonMap(DefaultGeographicCRS.NAME_KEY, NilReferencingObject.UNNAMED),
                    ((GeodeticCRS) crs).getDatum(), normalizedCS);
        }
        if (crs instanceof CompoundCRS) {
            for (final CoordinateReferenceSystem e : ((CompoundCRS) crs).getComponents()) {
                final GeographicCRS candidate = toNormalizedGeographicCRS(e, latlon, allow3D);
                if (candidate != null) {
                    return candidate;
                }
            }
        }
        return null;
    }

    /**
     * Returns the properties of the given object but potentially with a modified name.
     * Current implement truncates the name at the first non-white character which is not
     * a valid Unicode identifier part, with the following exception:
     *
     * <ul>
     *   <li>If the character is {@code '('} and the content until the closing {@code ')'} is a valid
     *       Unicode identifier, then that part is included. The intent is to keep the prime meridian
     *       name in names like <cite>"NTF (Paris)"</cite>.</li>
     * </ul>
     *
     * <div class="note"><b>Example:</b><ul>
     *   <li><cite>"NTF (Paris)"</cite> is left unchanged.</li>
     *   <li><cite>"WGS 84 (3D)"</cite> is truncated as <cite>"WGS 84"</cite>.</li>
     *   <li><cite>"Ellipsoidal 2D CS. Axes: latitude, longitude. Orientations: north, east. UoM: degree"</cite>
     *       is truncated as <cite>"Ellipsoidal 2D CS"</cite>.</li>
     * </ul></div>
     *
     * @param  object    the identified object to view as a properties map.
     * @return a view of the identified object properties.
     *
     * @see IdentifiedObjects#getProperties(IdentifiedObject, String...)
     */
    public static Map<String,?> getPropertiesForModifiedCRS(final IdentifiedObject object) {
        final Map<String,?> properties = IdentifiedObjects.getProperties(object, IdentifiedObject.IDENTIFIERS_KEY);
        final Identifier id = (Identifier) properties.get(IdentifiedObject.NAME_KEY);
        if (id != null) {
            String name = id.getCode();
            if (name != null) {
                for (int i=0; i < name.length();) {
                    final int c = name.codePointAt(i);
                    if (!Character.isUnicodeIdentifierPart(c) && !Character.isSpaceChar(c)) {
                        if (c == '(') {
                            final int endAt = name.indexOf(')', i);
                            if (endAt >= 0) {
                                final String extra = name.substring(i+1, endAt);
                                if (CharSequences.isUnicodeIdentifier(extra)) {
                                    i += extra.length() + 2;
                                }
                            }
                        }
                        name = CharSequences.trimWhitespaces(name, 0, i).toString();
                        if (!name.isEmpty()) {
                            final Map<String,Object> copy = new HashMap<>(properties);
                            copy.put(IdentifiedObject.NAME_KEY, name);
                            return copy;
                        }
                    }
                    i += Character.charCount(c);
                }
            }
        }
        return properties;
    }

    /**
     * Returns the XML property name of the given interface.
     *
     * For {@link CoordinateSystem} base type, the returned value shall be one of
     * {@code affineCS}, {@code cartesianCS}, {@code cylindricalCS}, {@code ellipsoidalCS}, {@code linearCS},
     * {@code parametricCS}, {@code polarCS}, {@code sphericalCS}, {@code timeCS} or {@code verticalCS}.
     *
     * @param  base  the abstract base interface.
     * @param  type  the interface or classes for which to get the XML property name.
     * @return the XML property name for the given class or interface, or {@code null} if none.
     *
     * @see WKTUtilities#toType(Class, Class)
     */
    public static StringBuilder toPropertyName(final Class<?> base, final Class<?> type) {
        final UML uml = type.getAnnotation(UML.class);
        if (uml != null) {
            final Specification spec = uml.specification();
            if (spec == Specification.ISO_19111) {
                final String name = uml.identifier();
                final int length = name.length();
                final StringBuilder buffer = new StringBuilder(length).append(name, name.indexOf('_') + 1, length);
                if (buffer.length() != 0) {
                    buffer.setCharAt(0, Character.toLowerCase(buffer.charAt(0)));
                    return buffer;
                }
            }
        }
        for (final Class<?> c : type.getInterfaces()) {
            if (base.isAssignableFrom(c)) {
                final StringBuilder name = toPropertyName(base, c);
                if (name != null) {
                    return name;
                }
            }
        }
        return null;
    }

    /**
     * Sets the source and target ellipsoids and coordinate systems to values inferred from the given CRS.
     * The ellipsoids will be non-null only if the given CRS is geographic (not geocentric).
     *
     * @param  sourceCRS  the CRS from which to get the source coordinate system and ellipsoid.
     * @param  targetCRS  the CRS from which to get the target coordinate system and ellipsoid.
     * @param  context    a pre-allocated context, or {@code null} for creating a new one.
     * @return the given context if it was non-null, or a new context otherwise.
     */
    public static Context createTransformContext(final CoordinateReferenceSystem sourceCRS,
            final CoordinateReferenceSystem targetCRS, Context context)
    {
        if (context == null) {
            context = new Context();
        }
        final CoordinateSystem sourceCS = (sourceCRS != null) ? sourceCRS.getCoordinateSystem() : null;
        final CoordinateSystem targetCS = (targetCRS != null) ? targetCRS.getCoordinateSystem() : null;
        if (sourceCRS instanceof GeodeticCRS && sourceCS instanceof EllipsoidalCS) {
            context.setSource((EllipsoidalCS) sourceCS, ((GeodeticCRS) sourceCRS).getDatum().getEllipsoid());
        } else {
            context.setSource(sourceCS);
        }
        if (targetCRS instanceof GeodeticCRS && targetCS instanceof EllipsoidalCS) {
            context.setTarget((EllipsoidalCS) targetCS, ((GeodeticCRS) targetCRS).getDatum().getEllipsoid());
        } else {
            context.setTarget(targetCS);
        }
        return context;
    }

    /**
     * Returns the mapping between parameter identifiers and parameter names as defined by the given authority.
     * This method assumes that the identifiers of all parameters defined by that authority are numeric.
     * Examples of authorities defining numeric parameters are EPSG and GeoTIFF.
     *
     * <p>The map returned by this method is modifiable. Callers are free to add or remove entries.</p>
     *
     * @param  parameters  the parameters for which to get a mapping from identifiers to names.
     * @param  authority   the authority defining the parameters.
     * @return mapping from parameter identifiers to parameter names defined by the given authority.
     * @throws NumberFormatException if a parameter identifier of the given authority is not numeric.
     * @throws IllegalArgumentException if the same identifier is used for two or more parameters.
     */
    public static Map<Integer,String> identifierToName(final ParameterDescriptorGroup parameters, final Citation authority) {
        final Map<Integer,String> mapping = new HashMap<>();
        for (final GeneralParameterDescriptor descriptor : parameters.descriptors()) {
            final Identifier id = IdentifiedObjects.getIdentifier(descriptor, authority);
            if (id != null) {
                String name = IdentifiedObjects.getName(descriptor, authority);
                if (name == null) {
                    name = IdentifiedObjects.getName(descriptor, null);
                }
                if (mapping.put(Integer.valueOf(id.getCode()), name) != null) {
                    throw new IllegalArgumentException(Errors.format(Errors.Keys.DuplicatedIdentifier_1, id));
                }
            }
        }
        return mapping;
    }

    /**
     * Returns short names for all axes of the given CRS. This method uses short names like "Latitude" or "Height",
     * even if the full ISO 19111 names are "Geodetic latitude" or "Ellipsoidal height". This is suitable as header
     * for columns in a table. This method does not include abbreviation or units in the returned names.
     *
     * @param  resources  the resources from which to get "latitude" and "longitude" localized labels.
     * @param  crs        the coordinate reference system from which to get axis names.
     * @return axis names, localized if possible.
     */
    public static String[] getShortAxisNames(final Vocabulary resources, final CoordinateReferenceSystem crs) {
        final boolean isGeographic = (crs instanceof GeographicCRS);
        final boolean isProjected  = (crs instanceof ProjectedCRS);
        final CoordinateSystem cs = crs.getCoordinateSystem();
        final String[] names = new String[cs.getDimension()];
        for (int i=0; i<names.length; i++) {
            short key = 0;
            final CoordinateSystemAxis axis = cs.getAxis(i);
            final AxisDirection direction = axis.getDirection();
            if (AxisDirections.isCardinal(direction)) {
                final boolean isMeridional = AxisDirection.NORTH.equals(direction) || AxisDirection.SOUTH.equals(direction);
                if (isGeographic) {
                    key = isMeridional ? Vocabulary.Keys.Latitude : Vocabulary.Keys.Longitude;
                } else if (isProjected) {
                    // We could add "Easting" / "Northing" here for ProjectedCRS in a future version.
                }
            } else if (AxisDirection.UP.equals(direction)) {
                if (isGeographic | isProjected) {
                    key = Vocabulary.Keys.Height;
                }
            }
            names[i] = (key != 0) ? resources.getString(key) : axis.getName().getCode();
        }
        return names;
    }
}
