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

import java.util.Locale;
import java.util.Formatter;
import java.util.Formattable;
import java.util.FormattableFlags;
import java.text.Format;
import java.text.ParseException;
import java.io.Serializable;
import javax.measure.Unit;
import javax.measure.IncommensurableException;
import org.opengis.geometry.DirectPosition;
import org.opengis.referencing.cs.AxisDirection;
import org.opengis.referencing.cs.CoordinateSystem;
import org.opengis.referencing.cs.CoordinateSystemAxis;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.apache.sis.util.Classes;
import org.apache.sis.util.internal.Strings;
import org.apache.sis.util.resources.Errors;

import static java.lang.Double.doubleToLongBits;
import static org.apache.sis.math.MathFunctions.isNegative;


/**
 * An angle in decimal degrees. An angle is the amount of rotation needed to bring one line or plane
 * into coincidence with another. Various kind of angles are used in geographic information systems,
 * some of them having a specialized class in Apache SIS:
 *
 * <ul>
 *   <li>{@linkplain Latitude} is an angle ranging from 0° at the equator to 90° at the poles.</li>
 *   <li>{@linkplain Longitude} is an angle measured east-west from a prime meridian (usually Greenwich, but not necessarily).</li>
 *   <li><cite>Azimuth</cite> is a direction given by an angle between 0° and 360° measured clockwise from North.</li>
 *   <li><cite>Bearing</cite> is a direction given by an angle between 0° and 90° in a quadrant defined by a cardinal direction.</li>
 *   <li><cite>Bearing</cite> is also sometimes used in navigation for an angle relative to the vessel forward direction.</li>
 *   <li><cite>Deflection angle</cite> is the angle between a line and the prolongation of a preceding line.</li>
 *   <li><cite>Interior angle</cite> is an angle measured between two lines of sight.</li>
 *   <li>{@linkplain ElevationAngle Elevation angle} is the angular height from the horizontal plane to an object above the horizon.</li>
 * </ul>
 *
 * <h2>Formatting angles</h2>
 * The recommended way to format angles is to instantiate an {@link AngleFormat} once, then to
 * reuse it many times. As a convenience, {@code Angle} objects can also be formatted by the
 * {@code "%s"} conversion specifier of {@link Formatter}, but this is less efficient for this
 * class.
 *
 * <h2>Immutability and thread safety</h2>
 * This class and the {@link Latitude} / {@link Longitude} subclasses are immutable, and thus
 * inherently thread-safe. Other subclasses may or may not be immutable, at implementation choice
 * (see {@link java.lang.Number} for an example of a similar in purpose class having mutable subclasses).
 *
 * @author  Martin Desruisseaux (MPO, IRD, Geomatys)
 * @version 0.8
 *
 * @see Latitude
 * @see Longitude
 * @see AngleFormat
 *
 * @since 0.3
 */
public class Angle implements Comparable<Angle>, Formattable, Serializable {
    /**
     * Serial number for inter-operability with different versions.
     */
    private static final long serialVersionUID = 3701568577051191744L;

    /**
     * A shared instance of {@link AngleFormat}.
     *
     * @see #getAngleFormat()
     */
    private static Format format;

    /**
     * Angle value in decimal degrees. We use decimal degrees as the storage unit
     * instead of radians in order to avoid rounding errors, since there is no
     * way to represent 30°, 45°, 90°, 180°, <i>etc.</i> in radians without errors.
     */
    private final double θ;

    /**
     * Constructs a new angle with the specified value in decimal degrees.
     *
     * @param  θ  angle in decimal degrees.
     */
    public Angle(final double θ) {
        this.θ = θ;
    }

    /**
     * Constructs a newly allocated {@code Angle} object that contain the angular value
     * represented by the string. The string should represent an angle in either fractional
     * degrees (e.g. 45.5°) or degrees with minutes and seconds (e.g. 45°30').
     *
     * <p>This is a convenience constructor mostly for testing purpose, since it uses a fixed
     * locale. Developers should consider using {@link AngleFormat} for end-user applications
     * instead of this constructor.</p>
     *
     * @param  text  a string to be converted to an {@code Angle}.
     * @throws NumberFormatException if the string does not contain a parsable angle.
     *
     * @see AngleFormat#parse(String)
     */
    public Angle(final String text) throws NumberFormatException {
        final Object angle;
        try {
            synchronized (Angle.class) {
                angle = getAngleFormat().parseObject(text);
            }
        } catch (ParseException exception) {
            /*
             * Use Exception.getMessage() instead of getLocalizedMessage() because the latter
             * is formatted in the AngleFormat locale, which is hard-coded to Locale.ROOT in our
             * 'getAngleFormat()' implementation. The getMessage() method uses the system locale,
             * which is what we actually want.
             */
            throw (NumberFormatException) new NumberFormatException(exception.getMessage()).initCause(exception);
        }
        final Class<?> type = angle.getClass();
        if (type == Angle.class || getClass().isAssignableFrom(type)) {
            this.θ = ((Angle) angle).θ;
        } else {
            throw new NumberFormatException(text);
        }
    }

    /**
     * Returns the angular value of the axis having the given direction.
     * This helper method is used for subclass constructors expecting a {@link DirectPosition} argument.
     *
     * @param  position  the position from which to get an angular value.
     * @param  positive  axis direction of positive values.
     * @param  negative  axis direction of negative values.
     * @return angular value in degrees.
     * @throws IllegalArgumentException if the given coordinate it not associated to a CRS,
     *         or if no axis oriented toward the given directions is found, or if that axis
     *         does not use {@linkplain Units#isAngular angular units}.
     */
    static double valueOf(final DirectPosition position, final AxisDirection positive, final AxisDirection negative) {
        final CoordinateReferenceSystem crs = position.getCoordinateReferenceSystem();
        if (crs == null) {
            throw new IllegalArgumentException(Errors.format(Errors.Keys.UnspecifiedCRS));
        }
        final CoordinateSystem cs = crs.getCoordinateSystem();
        final int dimension = cs.getDimension();
        IncommensurableException cause = null;
        for (int i=0; i<dimension; i++) {
            final CoordinateSystemAxis axis = cs.getAxis(i);
            final AxisDirection dir = axis.getDirection();
            final boolean isPositive = dir.equals(positive);
            if (isPositive || dir.equals(negative)) {
                double value = position.getOrdinate(i);
                if (!isPositive) value = -value;
                final Unit<?> unit = axis.getUnit();
                if (unit != Units.DEGREE) try {
                    value = unit.getConverterToAny(Units.DEGREE).convert(value);
                } catch (IncommensurableException e) {
                    cause = e;
                    break;
                }
                return value;
            }
        }
        throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalCRSType_1,
                Classes.getLeafInterfaces(crs.getClass(), CoordinateReferenceSystem.class)[0]), cause);
    }

    /**
     * Returns the angle value in decimal degrees.
     *
     * @return the angle value in decimal degrees.
     */
    public double degrees() {
        return θ;
    }

    /**
     * Returns the angle value in radians.
     *
     * @return the angle value in radians.
     */
    public double radians() {
        return Math.toRadians(θ);
    }

    /**
     * Returns a hash code for this {@code Angle} object.
     */
    @Override
    public int hashCode() {
        final long code = Double.doubleToLongBits(θ);
        return (int) code ^ (int) (code >>> 32) ^ (int) serialVersionUID;
    }

    /**
     * Compares the specified object with this angle for equality.
     *
     * @param  object  the object to compare with this angle for equality.
     * @return {@code true} if the given object is equal to this angle.
     */
    @Override
    public boolean equals(final Object object) {
        if (object == this) {
            return true;
        }
        if (object != null && getClass() == object.getClass()) {
            return doubleToLongBits(θ) == doubleToLongBits(((Angle) object).θ);
        }
        return false;
    }

    /**
     * Compares two {@code Angle} objects numerically. The comparison
     * is done as if by the {@link Double#compare(double, double)} method.
     *
     * @param  that  the angle to compare with this object for order.
     * @return -1 if this angle is smaller than the given one, +1 if greater or 0 if equals.
     */
    @Override
    public int compareTo(final Angle that) {
        return Double.compare(this.θ, that.θ);
    }

    /**
     * Upper threshold before to format an angle as an ordinary number.
     * This is set to 90° in the case of latitude numbers.
     */
    double maximum() {
        return 360;
    }

    /**
     * Returns the hemisphere character for an angle of the given sign.
     * This is used only by {@link #toString()}, not by {@link AngleFormat}.
     */
    char hemisphere(final boolean negative) {
        return 0;
    }

    /**
     * Returns a string representation of this {@code Angle} object.
     * This is a convenience method mostly for debugging purpose, since it uses a fixed locale.
     * Developers should consider using {@link AngleFormat} for end-user applications instead
     * than this method.
     *
     * @see AngleFormat#format(double)
     */
    @Override
    public String toString() {
        StringBuffer buffer = new StringBuffer();
        double m = Math.abs(θ);
        final boolean isSmall = m <= (1 / 3600E+3);                     // 1E-3 arc-second.
        if ((isSmall || m > maximum()) && m != 0) {
            final char h = hemisphere(isNegative(θ));
            if (h == 0) {
                m = θ;                                                  // Restore the sign.
            }
            char symbol = '°';
            if (isSmall) {
                symbol = '″';
                m *= 3600;
            }
            buffer.append(m).append(symbol);
            if (h != 0) {
                buffer.append(h);
            }
        } else {
            synchronized (Angle.class) {
                buffer = getAngleFormat().format(this, buffer, null);
            }
        }
        return buffer.toString();
    }

    /**
     * Returns a shared instance of {@link AngleFormat}. The return type is
     * {@link Format} in order to avoid class loading before necessary.
     *
     * <p>This method must be invoked in a {@code synchronized(Angle.class)} block. We use
     * synchronization instead of static class initialization because {@code AngleFormat}
     * is not thread-safe, so it needs to be used in a synchronized block anyway. We could
     * avoid synchronization by using {@link ThreadLocal}, but this brings other issues in
     * OSGi context. Given that our Javadoc said that {@link #Angle(String)} and {@link #toString()}
     * should be used mostly for debugging purpose, we consider not worth to ensure high
     * concurrency capability here.</p>
     */
    private static Format getAngleFormat() {
        assert Thread.holdsLock(Angle.class);
        if (format == null) {
            format = new AngleFormat(Locale.ROOT);
        }
        return format;
    }

    /**
     * Formats this angle using the provider formatter. This method is invoked when an
     * {@code Angle} object is formatted using the {@code "%s"} conversion specifier of
     * {@link Formatter}. Users don't need to invoke this method explicitly.
     *
     * <p>Special cases:</p>
     * <ul>
     *   <li>If the precision is 0, then this method formats an empty string.</li>
     *   <li>If the precision is 1 and this angle is a {@link Latitude} or {@link Longitude},
     *       then this method formats only the hemisphere symbol.</li>
     *   <li>Otherwise the precision, if positive, is given to {@link AngleFormat#setMaximumWidth(int)}.</li>
     * </ul>
     *
     * @param  formatter  the formatter in which to format this angle.
     * @param  flags      {@link FormattableFlags#LEFT_JUSTIFY} for left alignment, or 0 for right alignment.
     * @param  width      minimal number of characters to write, padding with {@code ' '} if necessary.
     * @param  precision  maximal number of characters to write, or -1 if no limit.
     */
    @Override
    public void formatTo(final Formatter formatter, final int flags, final int width, final int precision) {
        final String value;
        if (precision == 0) {
            value = "";
        } else {
            final char h;
            int w = precision;      // To be decremented only if we may truncate and an hemisphere symbol exist.
            if (w > 0 && (h = hemisphere(isNegative(θ))) != 0 && --w == 0) {
                value = Character.toString(h);
            } else {
                final AngleFormat format = new AngleFormat(formatter.locale());
                if (w > 0) {
                    format.setMaximumWidth(w);
                }
                value = format.format(this, new StringBuffer(), null).toString();
            }
        }
        Strings.formatTo(formatter, flags, width, precision, value);
    }
}
