# -*- coding: utf-8 -*-
# Licensed under a 3-clause BSD style license - see astropy's LICENSE.rst
#
# We include this file here since astropy shoved it around an eventually
# dropped it without much of a comment.  Hunting after the functions
# included here simply became too tedious.

"""
This module contains formatting functions that are for internal use in
astropy.coordinates.angles. Mainly they are conversions from one format
of data to another.
"""

from warnings import warn

import numpy as np

from astropy.utils.exceptions import AstropyWarning
from astropy.utils import format_exception
from astropy import units as u


class RangeError(ValueError):
    """
    Raised when some part of an angle is out of its valid range.
    """


class BoundsError(RangeError):
    """
    Raised when an angle is outside of its user-specified bounds.
    """


class IllegalHourError(RangeError):
    """
    Raised when an hour value is not in the range [0,24).

    Parameters
    ----------
    hour : int, float

    Examples
    --------

    .. code-block:: python

        if not 0 <= hr < 24:
           raise IllegalHourError(hour)
    """
    def __init__(self, hour):
        self.hour = hour

    def __str__(self):
        return f"An invalid value for 'hours' was found ('{self.hour}'); must be in the range [0,24)."


class IllegalHourWarning(AstropyWarning):
    """
    Raised when an hour value is 24.

    Parameters
    ----------
    hour : int, float
    """
    def __init__(self, hour, alternativeactionstr=None):
        self.hour = hour
        self.alternativeactionstr = alternativeactionstr

    def __str__(self):
        message = f"'hour' was found  to be '{self.hour}', which is not in range (-24, 24)."
        if self.alternativeactionstr is not None:
            message += ' ' + self.alternativeactionstr
        return message


class IllegalMinuteError(RangeError):
    """
    Raised when an minute value is not in the range [0,60].

    Parameters
    ----------
    minute : int, float

    Examples
    --------

    .. code-block:: python

        if not 0 <= min < 60:
            raise IllegalMinuteError(minute)

    """
    def __init__(self, minute):
        self.minute = minute

    def __str__(self):
        return f"An invalid value for 'minute' was found ('{self.minute}'); should be in the range [0,60)."


class IllegalMinuteWarning(AstropyWarning):
    """
    Raised when a minute value is 60.

    Parameters
    ----------
    minute : int, float
    """
    def __init__(self, minute, alternativeactionstr=None):
        self.minute = minute
        self.alternativeactionstr = alternativeactionstr

    def __str__(self):
        message = f"'minute' was found  to be '{self.minute}', which is not in range [0,60)."
        if self.alternativeactionstr is not None:
            message += ' ' + self.alternativeactionstr
        return message


class IllegalSecondError(RangeError):
    """
    Raised when an second value (time) is not in the range [0,60].

    Parameters
    ----------
    second : int, float

    Examples
    --------

    .. code-block:: python

        if not 0 <= sec < 60:
            raise IllegalSecondError(second)
    """
    def __init__(self, second):
        self.second = second

    def __str__(self):
        return f"An invalid value for 'second' was found ('{self.second}'); should be in the range [0,60)."


class IllegalSecondWarning(AstropyWarning):
    """
    Raised when a second value is 60.

    Parameters
    ----------
    second : int, float
    """
    def __init__(self, second, alternativeactionstr=None):
        self.second = second
        self.alternativeactionstr = alternativeactionstr

    def __str__(self):
        message = f"'second' was found  to be '{self.second}', which is not in range [0,60)."
        if self.alternativeactionstr is not None:
            message += ' ' + self.alternativeactionstr
        return message

def _check_hour_range(hrs):
    """
    Checks that the given value is in the range (-24, 24).
    """
    if np.any(np.abs(hrs) == 24.):
        warn(IllegalHourWarning(hrs, 'Treating as 24 hr'))
    elif np.any(hrs < -24.) or np.any(hrs > 24.):
        raise IllegalHourError(hrs)


def _check_minute_range(m):
    """
    Checks that the given value is in the range [0,60].  If the value
    is equal to 60, then a warning is raised.
    """
    if np.any(m == 60.):
        warn(IllegalMinuteWarning(m, 'Treating as 0 min, +1 hr/deg'))
    elif np.any(m < -60.) or np.any(m > 60.):
        # "Error: minutes not in range [-60,60) ({0}).".format(min))
        raise IllegalMinuteError(m)


def _check_second_range(sec):
    """
    Checks that the given value is in the range [0,60].  If the value
    is equal to 60, then a warning is raised.
    """
    if np.any(sec == 60.):
        warn(IllegalSecondWarning(sec, 'Treating as 0 sec, +1 min'))
    elif sec is None:
        pass
    elif np.any(sec < -60.) or np.any(sec > 60.):
        # "Error: seconds not in range [-60,60) ({0}).".format(sec))
        raise IllegalSecondError(sec)


def check_hms_ranges(h, m, s):
    """
    Checks that the given hour, minute and second are all within
    reasonable range.
    """
    _check_hour_range(h)
    _check_minute_range(m)
    _check_second_range(s)
    return None


def degrees_to_dms(d):
    """
    Convert a floating-point degree value into a ``(degree, arcminute,
    arcsecond)`` tuple.
    """
    sign = np.copysign(1.0, d)

    (df, d) = np.modf(np.abs(d))  # (degree fraction, degree)
    (mf, m) = np.modf(df * 60.)  # (minute fraction, minute)
    s = mf * 60.

    return np.floor(sign * d), sign * np.floor(m), sign * s


def dms_to_degrees(d, m, s=None):
    """
    Convert degrees, arcminute, arcsecond to a float degrees value.
     """
    _check_minute_range(m)
    _check_second_range(s)

    # if d is a scaler
    d = np.atleast_1d(d)
    m = np.atleast_1d(m)
    s = np.atleast_1d(s)
    dmss = np.array([d,m,s]).T
    sign = [1 for dms in dmss]
    for k in range(0,dmss.size/3):
        # looking for first non zero
        for i in dmss[k]:
            if i!=0:
                sign[k] = np.copysign(1,i); break;


    try:
        d = np.floor(np.abs(d))
        if s is None:
            m = np.abs(m)
            s = 0
        else:
            m = np.floor(np.abs(m))
            s = np.abs(s)
    except ValueError as err:
        raise ValueError(format_exception(
            "{func}: dms values ({1[0]},{2[1]},{3[2]}) could not be "
            "converted to numbers.", d, m, s)) from err

    return sign * (d + m / 60. + s / 3600.)


def hms_to_hours(h, m, s=None):
    """
    Convert hour, minute, second to a float hour value.
    """

    check_hms_ranges(h, m, s)

    # determine sign
    sign = np.copysign(1.0, h)

    try:
        h = np.floor(np.abs(h))
        if s is None:
            m = np.abs(m)
            s = 0
        else:
            m = np.floor(np.abs(m))
            s = np.abs(s)
    except ValueError as err:
        raise ValueError(format_exception(
            "{func}: HMS values ({1[0]},{2[1]},{3[2]}) could not be "
            "converted to numbers.", h, m, s)) from err

    return sign * (h + m / 60. + s / 3600.)


def hms_to_degrees(h, m, s):
    """
    Convert hour, minute, second to a float degrees value.
    """

    return hms_to_hours(h, m, s) * 15.


def hms_to_radians(h, m, s):
    """
    Convert hour, minute, second to a float radians value.
    """

    return u.degree.to(u.radian, hms_to_degrees(h, m, s))


def hms_to_dms(h, m, s):
    """
    Convert degrees, arcminutes, arcseconds to an ``(hour, minute, second)``
    tuple.
    """

    return degrees_to_dms(hms_to_degrees(h, m, s))


def hours_to_decimal(h):
    """
    Convert any parseable hour value into a float value.
    """
    from . import angles
    return angles.Angle(h, unit=u.hourangle).hour


def hours_to_radians(h):
    """
    Convert an angle in Hours to Radians.
    """

    return u.hourangle.to(u.radian, h)


def hours_to_hms(h):
    """
    Convert an floating-point hour value into an ``(hour, minute,
    second)`` tuple.
    """

    sign = np.copysign(1.0, h)

    (hf, h) = np.modf(np.abs(h))  # (degree fraction, degree)
    (mf, m) = np.modf(hf * 60.0)  # (minute fraction, minute)
    s = mf * 60.0

    return (np.floor(sign * h), sign * np.floor(m), sign * s)


def radians_to_degrees(r):
    """
    Convert an angle in Radians to Degrees.
    """
    return u.radian.to(u.degree, r)


def radians_to_hours(r):
    """
    Convert an angle in Radians to Hours.
    """
    return u.radian.to(u.hourangle, r)


def radians_to_hms(r):
    """
    Convert an angle in Radians to an ``(hour, minute, second)`` tuple.
    """

    hours = radians_to_hours(r)
    return hours_to_hms(hours)


def radians_to_dms(r):
    """
    Convert an angle in Radians to an ``(degree, arcminute,
    arcsecond)`` tuple.
    """

    degrees = u.radian.to(u.degree, r)
    return degrees_to_dms(degrees)


def sexagesimal_to_string(values, precision=None, pad=False, sep=(':',),
                          fields=3):
    """
    Given an already separated tuple of sexagesimal values, returns
    a string.

    See `hours_to_string` and `degrees_to_string` for a higher-level
    interface to this functionality.
    """

    # Check to see if values[0] is negative, using np.copysign to handle -0
    sign = np.copysign(1.0, values[0])
    # If the coordinates are negative, we need to take the absolute values.
    # We use np.abs because abs(-0) is -0
    # TODO: Is this true? (MHvK, 2018-02-01: not on my system)
    values = [np.abs(value) for value in values]

    if pad:
        if sign == -1:
            pad = 3
        else:
            pad = 2
    else:
        pad = 0

    if not isinstance(sep, tuple):
        sep = tuple(sep)

    if fields < 1 or fields > 3:
        raise ValueError(
            "fields must be 1, 2, or 3")

    if not sep:  # empty string, False, or None, etc.
        sep = ('', '', '')
    elif len(sep) == 1:
        if fields == 3:
            sep = sep + (sep[0], '')
        elif fields == 2:
            sep = sep + ('', '')
        else:
            sep = ('', '', '')
    elif len(sep) == 2:
        sep = sep + ('',)
    elif len(sep) != 3:
        raise ValueError(
            "Invalid separator specification for converting angle to string.")

    # Simplify the expression based on the requested precision.  For
    # example, if the seconds will round up to 60, we should convert
    # it to 0 and carry upwards.  If the field is hidden (by the
    # fields kwarg) we round up around the middle, 30.0.
    if precision is None:
        rounding_thresh = 60.0 - (10.0 ** -8)
    else:
        rounding_thresh = 60.0 - (10.0 ** -precision)

    if fields == 3 and values[2] >= rounding_thresh:
        values[2] = 0.0
        values[1] += 1.0
    elif fields < 3 and values[2] >= 30.0:
        values[1] += 1.0

    if fields >= 2 and values[1] >= 60.0:
        values[1] = 0.0
        values[0] += 1.0
    elif fields < 2 and values[1] >= 30.0:
        values[0] += 1.0

    literal = []
    last_value = ''
    literal.append('{0:0{pad}.0f}{sep[0]}')
    if fields >= 2:
        literal.append('{1:02d}{sep[1]}')
    if fields == 3:
        if precision is None:
            last_value = f'{abs(values[2]):.8f}'
            last_value = last_value.rstrip('0').rstrip('.')
        else:
            last_value = '{0:.{precision}f}'.format(
                abs(values[2]), precision=precision)
        if len(last_value) == 1 or last_value[1] == '.':
            last_value = '0' + last_value
        literal.append('{last_value}{sep[2]}')
    literal = ''.join(literal)
    return literal.format(np.copysign(values[0], sign),
                          int(values[1]), values[2],
                          sep=sep, pad=pad,
                          last_value=last_value)


def hours_to_string(h, precision=5, pad=False, sep=('h', 'm', 's'),
                    fields=3):
    """
    Takes a decimal hour value and returns a string formatted as hms with
    separator specified by the 'sep' parameter.

    ``h`` must be a scalar.
    """
    h, m, s = hours_to_hms(h)
    return sexagesimal_to_string((h, m, s), precision=precision, pad=pad,
                                 sep=sep, fields=fields)


def degrees_to_string(d, precision=5, pad=False, sep=':', fields=3):
    """
    Takes a decimal hour value and returns a string formatted as dms with
    separator specified by the 'sep' parameter.

    ``d`` must be a scalar.
    """
    d, m, s = degrees_to_dms(d)
    return sexagesimal_to_string((d, m, s), precision=precision, pad=pad,
                                 sep=sep, fields=fields)
