# Licensed under a 3-clause BSD style license - see LICENSE.rst


# This file defines the AngleFormatterLocator class which is a class that
# provides both a method for a formatter and one for a locator, for a given
# label spacing. The advantage of keeping the two connected is that we need to
# make sure that the formatter can correctly represent the spacing requested and
# vice versa. For example, a format of dd:mm cannot work with a tick spacing
# that is not a multiple of one arcminute.

import re
import warnings

import numpy as np
from matplotlib import rcParams
from matplotlib.ticker import Formatter

from astropy import units as u
from astropy.coordinates import Angle
from astropy.units import UnitsError

DMS_RE = re.compile("^dd(:mm(:ss(.(s)+)?)?)?$")
HMS_RE = re.compile("^hh(:mm(:ss(.(s)+)?)?)?$")
DDEC_RE = re.compile("^d(.(d)+)?$")
DMIN_RE = re.compile("^m(.(m)+)?$")
DSEC_RE = re.compile("^s(.(s)+)?$")
SCAL_RE = re.compile("^x(.(x)+)?$")


# Units with custom representations - see the note where it is used inside
# AngleFormatterLocator.formatter for more details.

CUSTOM_UNITS = {
    u.degree: u.def_unit(
        "custom_degree",
        represents=u.degree,
        format={"generic": "\xb0", "latex": r"^\circ", "unicode": "°"},
    ),
    u.arcmin: u.def_unit(
        "custom_arcmin",
        represents=u.arcmin,
        format={"generic": "'", "latex": r"^\prime", "unicode": "′"},
    ),
    u.arcsec: u.def_unit(
        "custom_arcsec",
        represents=u.arcsec,
        format={"generic": '"', "latex": r"^{\prime\prime}", "unicode": "″"},
    ),
    u.hourangle: u.def_unit(
        "custom_hourangle",
        represents=u.hourangle,
        format={
            "generic": "h",
            "latex": r"^{\mathrm{h}}",
            "unicode": r"$\mathregular{^h}$",
        },
    ),
}


def _fix_minus(labels: list[str], /) -> list[str]:
    # correctly support axes.unicode_minus, but do it in a
    # way that preserves arbitrary separators: only fix the leading character
    # see https://github.com/astropy/astropy/issues/15898
    return [Formatter.fix_minus(s[0]) + s[1:] for s in labels]


class BaseFormatterLocator:
    """
    A joint formatter/locator.
    """

    def __init__(
        self,
        values=None,
        number=None,
        spacing=None,
        format=None,
        unit=None,
        format_unit=None,
    ):
        if len([x for x in (values, number, spacing) if x is None]) < 2:
            raise ValueError("At most one of values/number/spacing can be specified")

        self._unit = unit
        self._format_unit = format_unit or unit

        if values is not None:
            self.values = values
        elif number is not None:
            self.number = number
        elif spacing is not None:
            self.spacing = spacing
        else:
            self.number = 5

        self.format = format

    @property
    def values(self):
        return self._values

    @values.setter
    def values(self, values):
        if not isinstance(values, u.Quantity) or (not values.ndim == 1):
            raise TypeError("values should be an astropy.units.Quantity array")
        if not values.unit.is_equivalent(self._unit):
            raise UnitsError(
                "value should be in units compatible with "
                f"coordinate units ({self._unit}) but found {values.unit}"
            )
        self._number = None
        self._spacing = None
        self._values = values

    @property
    def number(self):
        return self._number

    @number.setter
    def number(self, number):
        self._number = number
        self._spacing = None
        self._values = None

    @property
    def spacing(self):
        return self._spacing

    @spacing.setter
    def spacing(self, spacing):
        self._number = None
        self._spacing = spacing
        self._values = None

    def minor_locator(self, spacing, frequency, value_min, value_max):
        if self.values is not None:
            return [] * self._unit

        minor_spacing = spacing.value / frequency
        values = self._locate_values(value_min, value_max, minor_spacing)
        index = np.where((values % frequency) == 0)
        index = index[0][0]
        values = np.delete(values, np.s_[index::frequency])
        return values * minor_spacing * self._unit

    @property
    def format_unit(self):
        return self._format_unit

    @format_unit.setter
    def format_unit(self, unit):
        self._format_unit = u.Unit(unit)

    @staticmethod
    def _locate_values(value_min, value_max, spacing):
        imin = np.ceil(value_min / spacing)
        imax = np.floor(value_max / spacing)
        return np.arange(imin, imax + 1, dtype=int)


class AngleFormatterLocator(BaseFormatterLocator):
    """
    A joint formatter/locator.

    Parameters
    ----------
    number : int, optional
        Number of ticks.
    """

    def __init__(
        self,
        values=None,
        number=None,
        spacing=None,
        format=None,
        unit=None,
        decimal=None,
        format_unit=None,
        show_decimal_unit=True,
    ):
        if unit is None:
            unit = u.degree

        if format_unit is None:
            format_unit = unit

        if format_unit not in (u.degree, u.hourangle, u.hour):
            if decimal is False:
                raise UnitsError(
                    "Units should be degrees or hours when using non-decimal"
                    " (sexagesimal) mode"
                )

        self._decimal = decimal
        self._sep = None
        self.show_decimal_unit = show_decimal_unit
        self._alwayssign = False

        super().__init__(
            values=values,
            number=number,
            spacing=spacing,
            format=format,
            unit=unit,
            format_unit=format_unit,
        )

    @property
    def decimal(self):
        decimal = self._decimal
        if self.format_unit not in (u.degree, u.hourangle, u.hour):
            if self._decimal is None:
                decimal = True
            elif self._decimal is False:
                raise UnitsError(
                    "Units should be degrees or hours when using non-decimal"
                    " (sexagesimal) mode"
                )
        elif self._decimal is None:
            decimal = False
        return decimal

    @decimal.setter
    def decimal(self, value):
        self._decimal = value

    @property
    def spacing(self):
        return self._spacing

    @spacing.setter
    def spacing(self, spacing):
        if spacing is not None and (
            not isinstance(spacing, u.Quantity) or spacing.unit.physical_type != "angle"
        ):
            raise TypeError(
                "spacing should be an astropy.units.Quantity "
                "instance with units of angle"
            )
        self._number = None
        self._spacing = spacing
        self._values = None

    @property
    def sep(self):
        return self._sep

    @sep.setter
    def sep(self, separator):
        self._sep = separator

    @property
    def format(self):
        return self._format

    @format.setter
    def format(self, value):
        self._format = value

        if value is None:
            return

        self._alwayssign = value.startswith("+")
        if self._alwayssign:
            value = value[1:]

        if DMS_RE.match(value) is not None:
            self._decimal = False
            self._format_unit = u.degree
            if "." in value:
                self._precision = len(value) - value.index(".") - 1
                self._fields = 3
            else:
                self._precision = 0
                self._fields = value.count(":") + 1
        elif HMS_RE.match(value) is not None:
            self._decimal = False
            self._format_unit = u.hourangle
            if "." in value:
                self._precision = len(value) - value.index(".") - 1
                self._fields = 3
            else:
                self._precision = 0
                self._fields = value.count(":") + 1
        elif DDEC_RE.match(value) is not None:
            self._decimal = True
            self._format_unit = u.degree
            self._fields = 1
            if "." in value:
                self._precision = len(value) - value.index(".") - 1
            else:
                self._precision = 0
        elif DMIN_RE.match(value) is not None:
            self._decimal = True
            self._format_unit = u.arcmin
            self._fields = 1
            if "." in value:
                self._precision = len(value) - value.index(".") - 1
            else:
                self._precision = 0
        elif DSEC_RE.match(value) is not None:
            self._decimal = True
            self._format_unit = u.arcsec
            self._fields = 1
            if "." in value:
                self._precision = len(value) - value.index(".") - 1
            else:
                self._precision = 0
        else:
            raise ValueError(f"Invalid format: {value}")

        if self.spacing is not None and self.spacing < self.base_spacing:
            warnings.warn("Spacing is too small - resetting spacing to match format")
            self.spacing = self.base_spacing

        if self.spacing is not None:
            ratio = (self.spacing / self.base_spacing).decompose().value
            remainder = ratio - np.round(ratio)

            if abs(remainder) > 1.0e-10:
                warnings.warn(
                    "Spacing is not a multiple of base spacing - resetting spacing to"
                    " match format"
                )
                self.spacing = self.base_spacing * max(1, round(ratio))

    @property
    def base_spacing(self):
        if self.decimal:
            spacing = self._format_unit / (10.0**self._precision)

        else:
            if self._fields == 1:
                spacing = 1.0 * u.degree
            elif self._fields == 2:
                spacing = 1.0 * u.arcmin
            elif self._fields == 3:
                if self._precision == 0:
                    spacing = 1.0 * u.arcsec
                else:
                    spacing = u.arcsec / (10.0**self._precision)

        if self._format_unit is u.hourangle:
            spacing *= 15

        return spacing

    def locator(self, value_min, value_max):
        if self.values is not None:
            # values were manually specified
            return self.values, 1.1 * u.arcsec

        else:
            # In the special case where value_min is the same as value_max, we
            # don't locate any ticks. This can occur for example when taking a
            # slice for a cube (along the dimension sliced). We return a
            # non-zero spacing in case the caller needs to format a single
            # coordinate, e.g. for mousover.
            if value_min == value_max:
                return [] * self._unit, 1 * u.arcsec

            if self.spacing is not None:
                # spacing was manually specified
                spacing_value = self.spacing.to_value(self._unit)

            elif self.number == 0:
                return [] * self._unit, np.nan * self._unit

            elif self.number is not None:
                # number of ticks was specified, work out optimal spacing

                # first compute the exact spacing
                dv = abs(float(value_max - value_min)) / self.number * self._unit

                if self.format is not None and dv < self.base_spacing:
                    # if the spacing is less than the minimum spacing allowed by the format, simply
                    # use the format precision instead.
                    spacing_value = self.base_spacing.to_value(self._unit)
                else:
                    # otherwise we clip to the nearest 'sensible' spacing
                    if self.decimal:
                        from .utils import select_step_scalar

                        spacing_value = select_step_scalar(
                            dv.to_value(self._format_unit)
                        ) * self._format_unit.to(self._unit)
                    else:
                        if self._format_unit is u.degree:
                            from .utils import select_step_degree

                            spacing_value = select_step_degree(dv).to_value(self._unit)
                        else:
                            from .utils import select_step_hour

                            spacing_value = select_step_hour(dv).to_value(self._unit)

            # We now find the interval values as multiples of the spacing and
            # generate the tick positions from this.
            values = self._locate_values(value_min, value_max, spacing_value)
            return values * spacing_value * self._unit, spacing_value * self._unit

    def formatter(self, values, spacing, format="auto"):
        if not isinstance(values, u.Quantity) and values is not None:
            raise TypeError("values should be a Quantities array")

        if len(values) > 0:
            decimal = self.decimal
            unit = self._format_unit

            if unit is u.hour:
                unit = u.hourangle

            if self.format is None:
                if decimal:
                    # Here we assume the spacing can be arbitrary, so for example
                    # 1.000223 degrees, in which case we don't want to have a
                    # format that rounds to degrees. So we find the number of
                    # decimal places we get from representing the spacing as a
                    # string in the desired units. The easiest way to find
                    # the smallest number of decimal places required is to
                    # format the number as a decimal float and strip any zeros
                    # from the end. We do this rather than just trusting e.g.
                    # str() because str(15.) == 15.0. We format using 10 decimal
                    # places by default before stripping the zeros since this
                    # corresponds to a resolution of less than a microarcecond,
                    # which should be sufficient.
                    spacing = spacing.to_value(unit)
                    fields = 0
                    precision = len(
                        f"{spacing:.10f}".replace("0", " ").strip().split(".", 1)[1]
                    )
                else:
                    spacing = spacing.to_value(unit / 3600)
                    if spacing >= 3600:
                        fields = 1
                        precision = 0
                    elif spacing >= 60:
                        fields = 2
                        precision = 0
                    elif spacing >= 1:
                        fields = 3
                        precision = 0
                    else:
                        fields = 3
                        precision = -int(np.floor(np.log10(spacing)))
            else:
                fields = self._fields
                precision = self._precision

            is_latex = format == "latex" or (
                format == "auto" and rcParams["text.usetex"]
            )

            if decimal:
                if self.show_decimal_unit:
                    sep = "fromunit"
                    if is_latex:
                        fmt = "latex"
                    else:
                        if unit is u.hourangle:
                            fmt = "unicode"
                        else:
                            fmt = "generic"
                    unit = CUSTOM_UNITS.get(unit, unit)
                else:
                    sep = "fromunit"
                    fmt = None
            elif self.sep is not None:
                sep = self.sep
                fmt = None
            else:
                sep = "fromunit"
                if unit == u.degree:
                    if is_latex:
                        fmt = "latex"
                    else:
                        sep = ("\xb0", "'", '"')
                        fmt = None
                else:
                    if format == "ascii":
                        fmt = None
                    elif is_latex:
                        fmt = "latex"
                    else:
                        # Here we still use LaTeX but this is for Matplotlib's
                        # LaTeX engine - we can't use fmt='latex' as this
                        # doesn't produce LaTeX output that respects the fonts.
                        sep = (
                            r"$\mathregular{^h}$",
                            r"$\mathregular{^m}$",
                            r"$\mathregular{^s}$",
                        )
                        fmt = None

            angles = Angle(values)
            string = angles.to_string(
                unit=unit,
                precision=precision,
                decimal=decimal,
                fields=fields,
                sep=sep,
                format=fmt,
                alwayssign=self._alwayssign,
            ).tolist()

            return _fix_minus(string)
        else:
            return []


class ScalarFormatterLocator(BaseFormatterLocator):
    """
    A joint formatter/locator.
    """

    def __init__(
        self,
        values=None,
        number=None,
        spacing=None,
        format=None,
        unit=None,
        format_unit=None,
    ):
        if unit is None:
            if spacing is not None:
                unit = spacing.unit
            elif values is not None:
                unit = values.unit
        format_unit = format_unit or unit

        super().__init__(
            values=values,
            number=number,
            spacing=spacing,
            format=format,
            unit=unit,
            format_unit=format_unit,
        )

    @property
    def spacing(self):
        return self._spacing

    @spacing.setter
    def spacing(self, spacing):
        if spacing is not None and not isinstance(spacing, u.Quantity):
            raise TypeError("spacing should be an astropy.units.Quantity instance")
        self._number = None
        self._spacing = spacing
        self._values = None

    @property
    def format(self):
        return self._format

    @format.setter
    def format(self, value):
        self._format = value

        if value is None:
            return

        if SCAL_RE.match(value) is not None:
            if "." in value:
                self._precision = len(value) - value.index(".") - 1
            else:
                self._precision = 0

            if self.spacing is not None and self.spacing < self.base_spacing:
                warnings.warn(
                    "Spacing is too small - resetting spacing to match format"
                )
                self.spacing = self.base_spacing

            if self.spacing is not None:
                ratio = (self.spacing / self.base_spacing).decompose().value
                remainder = ratio - np.round(ratio)

                if abs(remainder) > 1.0e-10:
                    warnings.warn(
                        "Spacing is not a multiple of base spacing - resetting spacing"
                        " to match format"
                    )
                    self.spacing = self.base_spacing * max(1, round(ratio))

        elif not value.startswith("%"):
            raise ValueError(f"Invalid format: {value}")

    @property
    def base_spacing(self):
        return self._format_unit / (10.0**self._precision)

    def locator(self, value_min, value_max):
        if self.values is not None:
            # values were manually specified
            return self.values, 1.1 * self._unit
        else:
            # In the special case where value_min is the same as value_max, we
            # don't locate any ticks. This can occur for example when taking a
            # slice for a cube (along the dimension sliced).
            if value_min == value_max:
                return [] * self._unit, 0 * self._unit

            if self.spacing is not None:
                # spacing was manually specified
                spacing = self.spacing.to_value(self._unit)

            elif self.number is not None:
                # number of ticks was specified, work out optimal spacing

                # first compute the exact spacing
                dv = abs(float(value_max - value_min)) / self.number * self._unit

                if (
                    self.format is not None
                    and (not self.format.startswith("%"))
                    and dv < self.base_spacing
                ):
                    # if the spacing is less than the minimum spacing allowed by the format, simply
                    # use the format precision instead.
                    spacing = self.base_spacing.to_value(self._unit)
                else:
                    from .utils import select_step_scalar

                    spacing = select_step_scalar(
                        dv.to_value(self._format_unit)
                    ) * self._format_unit.to(self._unit)

            # We now find the interval values as multiples of the spacing and
            # generate the tick positions from this

            values = self._locate_values(value_min, value_max, spacing)
            return values * spacing * self._unit, spacing * self._unit

    def formatter(self, values, spacing, format="auto"):
        if len(values) > 0:
            if self.format is None:
                if spacing.value < 1.0:
                    precision = -int(np.floor(np.log10(spacing.value)))
                else:
                    precision = 0
            elif self.format.startswith("%"):
                return [(self.format % x.value) for x in values]
            else:
                precision = self._precision

            return _fix_minus(
                [
                    ("{0:." + str(precision) + "f}").format(
                        x.to_value(self._format_unit)
                    )
                    for x in values
                ]
            )

        else:
            return []
