# Licensed under a 3-clause BSD style license - see LICENSE.rst
# pylint: disable=invalid-name

"""
This module defines classes that deal with parameters.

It is unlikely users will need to work with these classes directly,
unless they define their own models.
"""


import functools
import numbers
import operator

import numpy as np

from astropy.units import Quantity
from astropy.utils import isiterable

from .utils import array_repr_oneline, get_inputs_and_params

__all__ = ['Parameter', 'InputParameterError', 'ParameterError']


class ParameterError(Exception):
    """Generic exception class for all exceptions pertaining to Parameters."""


class InputParameterError(ValueError, ParameterError):
    """Used for incorrect input parameter values and definitions."""


class ParameterDefinitionError(ParameterError):
    """Exception in declaration of class-level Parameters."""


def _tofloat(value):
    """Convert a parameter to float or float array"""

    if isiterable(value):
        try:
            value = np.asanyarray(value, dtype=float)
        except (TypeError, ValueError):
            # catch arrays with strings or user errors like different
            # types of parameters in a parameter set
            raise InputParameterError(
                f"Parameter of {type(value)} could not be converted to float")
    elif isinstance(value, Quantity):
        # Quantities are fine as is
        pass
    elif isinstance(value, np.ndarray):
        # A scalar/dimensionless array
        value = float(value.item())
    elif isinstance(value, (numbers.Number, np.number)) and not isinstance(value, bool):
        value = float(value)
    elif isinstance(value, bool):
        raise InputParameterError(
            "Expected parameter to be of numerical type, not boolean")
    else:
        raise InputParameterError(
            f"Don't know how to convert parameter of {type(value)} to float")
    return value


# Helpers for implementing operator overloading on Parameter

def _binary_arithmetic_operation(op, reflected=False):
    @functools.wraps(op)
    def wrapper(self, val):

        if self.unit is not None:
            self_value = Quantity(self.value, self.unit)
        else:
            self_value = self.value

        if reflected:
            return op(val, self_value)
        else:
            return op(self_value, val)

    return wrapper


def _binary_comparison_operation(op):
    @functools.wraps(op)
    def wrapper(self, val):

        if self.unit is not None:
            self_value = Quantity(self.value, self.unit)
        else:
            self_value = self.value

        return op(self_value, val)

    return wrapper


def _unary_arithmetic_operation(op):
    @functools.wraps(op)
    def wrapper(self):

        if self.unit is not None:
            self_value = Quantity(self.value, self.unit)
        else:
            self_value = self.value

        return op(self_value)

    return wrapper


class Parameter:
    """
    Wraps individual parameters.

    Since 4.0 Parameters are no longer descriptors and are based on a new
    implementation of the Parameter class. Parameters now  (as of 4.0) store
    values locally (as instead previously in the associated model)

    This class represents a model's parameter (in a somewhat broad sense). It
    serves a number of purposes:

    1) A type to be recognized by models and treated specially at class
    initialization (i.e., if it is found that there is a class definition
    of a Parameter, the model initializer makes a copy at the instance level).

    2) Managing the handling of allowable parameter values and once defined,
    ensuring updates are consistent with the Parameter definition. This
    includes the optional use of units and quantities as well as transforming
    values to an internally consistent representation (e.g., from degrees to
    radians through the use of getters and setters).

    3) Holding attributes of parameters relevant to fitting, such as whether
    the parameter may be varied in fitting, or whether there are constraints
    that must be satisfied.



    See :ref:`astropy:modeling-parameters` for more details.

    Parameters
    ----------
    name : str
        parameter name

        .. warning::

            The fact that `Parameter` accepts ``name`` as an argument is an
            implementation detail, and should not be used directly.  When
            defining a new `Model` class, parameter names are always
            automatically defined by the class attribute they're assigned to.
    description : str
        parameter description
    default : float or array
        default value to use for this parameter
    unit : `~astropy.units.Unit`
        if specified, the parameter will be in these units, and when the
        parameter is updated in future, it should be set to a
        :class:`~astropy.units.Quantity` that has equivalent units.
    getter : callable
        a function that wraps the raw (internal) value of the parameter
        when returning the value through the parameter proxy (eg. a
        parameter may be stored internally as radians but returned to the
        user as degrees)
    setter : callable
        a function that wraps any values assigned to this parameter; should
        be the inverse of getter
    fixed : bool
        if True the parameter is not varied during fitting
    tied : callable or False
        if callable is supplied it provides a way to link the value of this
        parameter to another parameter (or some other arbitrary function)
    min : float
        the lower bound of a parameter
    max : float
        the upper bound of a parameter
    bounds : tuple
        specify min and max as a single tuple--bounds may not be specified
        simultaneously with min or max
    """

    constraints = ('fixed', 'tied', 'bounds')
    """
    Types of constraints a parameter can have.  Excludes 'min' and 'max'
    which are just aliases for the first and second elements of the 'bounds'
    constraint (which is represented as a 2-tuple). 'prior' and 'posterior'
    are available for use by user fitters but are not used by any built-in
    fitters as of this writing.
    """

    def __init__(self, name='', description='', default=None, unit=None,
                 getter=None, setter=None, fixed=False, tied=False, min=None,
                 max=None, bounds=None, prior=None, posterior=None):
        super().__init__()

        self._model = None
        self._model_required = False
        self._setter = self._create_value_wrapper(setter, None)
        self._getter = self._create_value_wrapper(getter, None)
        self._name = name
        self.__doc__ = self._description = description.strip()

        # We only need to perform this check on unbound parameters
        if isinstance(default, Quantity):
            if unit is not None and not unit.is_equivalent(default.unit):
                raise ParameterDefinitionError(
                    f"parameter default {default} does not have units equivalent to "
                    f"the required unit {unit}")
            unit = default.unit
            default = default.value

        self._default = default
        self._unit = unit
        # Internal units correspond to raw_units held by the model in the
        # previous implementation. The private _getter and _setter methods
        # use this to convert to and from the public unit defined for the
        # parameter.
        self._internal_unit = None
        if not self._model_required:
            if self._default is not None:
                self.value = self._default
            else:
                self._value = None

        # NOTE: These are *default* constraints--on model instances constraints
        # are taken from the model if set, otherwise the defaults set here are
        # used
        if bounds is not None:
            if min is not None or max is not None:
                raise ValueError(
                    "bounds may not be specified simultaneously with min or "
                    f"max when instantiating Parameter {name}")
        else:
            bounds = (min, max)

        self._fixed = fixed
        self._tied = tied
        self._bounds = bounds
        self._order = None

        self._validator = None
        self._prior = prior
        self._posterior = posterior

        self._std = None

    def __set_name__(self, owner, name):
        self._name = name

    def __len__(self):
        val = self.value
        if val.shape == ():
            return 1
        else:
            return val.shape[0]

    def __getitem__(self, key):
        value = self.value
        if len(value.shape) == 0:
            # Wrap the value in a list so that getitem can work for sensible
            # indices like [0] and [-1]
            value = [value]
        return value[key]

    def __setitem__(self, key, value):
        # Get the existing value and check whether it even makes sense to
        # apply this index
        oldvalue = self.value
        if isinstance(key, slice):
            if len(oldvalue[key]) == 0:
                raise InputParameterError(
                    "Slice assignment outside the parameter dimensions for "
                    f"'{self.name}'")
            for idx, val in zip(range(*key.indices(len(self))), value):
                self.__setitem__(idx, val)
        else:
            try:
                oldvalue[key] = value
            except IndexError:
                raise InputParameterError(
                    f"Input dimension {key} invalid for {self.name!r} parameter with "
                    f"dimension {value.shape[0]}")  # likely wrong

    def __repr__(self):
        args = f"'{self._name}'"
        args += f', value={self.value}'

        if self.unit is not None:
            args += f', unit={self.unit}'

        for cons in self.constraints:
            val = getattr(self, cons)
            if val not in (None, False, (None, None)):
                # Maybe non-obvious, but False is the default for the fixed and
                # tied constraints
                args += f', {cons}={val}'

        return f"{self.__class__.__name__}({args})"

    @property
    def name(self):
        """Parameter name"""

        return self._name

    @property
    def default(self):
        """Parameter default value"""
        return self._default

    @property
    def value(self):
        """The unadorned value proxied by this parameter."""
        if self._getter is None and self._setter is None:
            return np.float64(self._value)
        else:
            # This new implementation uses the names of internal_unit
            # in place of raw_unit used previously. The contrast between
            # internal values and units is that between the public
            # units that the parameter advertises to what it actually
            # uses internally.
            if self.internal_unit:
                return np.float64(self._getter(self._internal_value,
                                               self.internal_unit,
                                               self.unit).value)
            elif self._getter:
                return np.float64(self._getter(self._internal_value))
            elif self._setter:
                return np.float64(self._internal_value)

    @value.setter
    def value(self, value):
        if isinstance(value, Quantity):
            raise TypeError("The .value property on parameters should be set"
                            " to unitless values, not Quantity objects. To set"
                            "a parameter to a quantity simply set the "
                            "parameter directly without using .value")
        if self._setter is None:
            self._value = np.array(value, dtype=np.float64)
        else:
            self._internal_value = np.array(self._setter(value),
                                            dtype=np.float64)

    @property
    def unit(self):
        """
        The unit attached to this parameter, if any.

        On unbound parameters (i.e. parameters accessed through the
        model class, rather than a model instance) this is the required/
        default unit for the parameter.
        """

        return self._unit

    @unit.setter
    def unit(self, unit):
        if self.unit is None:
            raise ValueError('Cannot attach units to parameters that were '
                             'not initially specified with units')
        else:
            raise ValueError('Cannot change the unit attribute directly, '
                             'instead change the parameter to a new quantity')

    def _set_unit(self, unit, force=False):
        if force:
            self._unit = unit
        else:
            self.unit = unit

    @property
    def internal_unit(self):
        """
        Return the internal unit the parameter uses for the internal value stored
        """
        return self._internal_unit

    @internal_unit.setter
    def internal_unit(self, internal_unit):
        """
        Set the unit the parameter will convert the supplied value to the
        representation used internally.
        """
        self._internal_unit = internal_unit

    @property
    def quantity(self):
        """
        This parameter, as a :class:`~astropy.units.Quantity` instance.
        """
        if self.unit is None:
            return None
        return self.value * self.unit

    @quantity.setter
    def quantity(self, quantity):
        if not isinstance(quantity, Quantity):
            raise TypeError("The .quantity attribute should be set "
                            "to a Quantity object")
        self.value = quantity.value
        self._unit = quantity.unit

    @property
    def shape(self):
        """The shape of this parameter's value array."""
        if self._setter is None:
            return self._value.shape
        return self._internal_value.shape

    @shape.setter
    def shape(self, value):
        if isinstance(self.value, np.generic):
            if value not in ((), (1,)):
                raise ValueError("Cannot assign this shape to a scalar quantity")
        else:
            self.value.shape = value

    @property
    def size(self):
        """The size of this parameter's value array."""

        return np.size(self.value)

    @property
    def std(self):
        """Standard deviation, if available from fit."""

        return self._std

    @std.setter
    def std(self, value):

        self._std = value

    @property
    def prior(self):
        return self._prior

    @prior.setter
    def prior(self, val):
        self._prior = val

    @property
    def posterior(self):
        return self._posterior

    @posterior.setter
    def posterior(self, val):
        self._posterior = val

    @property
    def fixed(self):
        """
        Boolean indicating if the parameter is kept fixed during fitting.
        """
        return self._fixed

    @fixed.setter
    def fixed(self, value):
        """ Fix a parameter. """
        if not isinstance(value, bool):
            raise ValueError("Value must be boolean")
        self._fixed = value

    @property
    def tied(self):
        """
        Indicates that this parameter is linked to another one.

        A callable which provides the relationship of the two parameters.
        """

        return self._tied

    @tied.setter
    def tied(self, value):
        """Tie a parameter"""

        if not callable(value) and value not in (False, None):
            raise TypeError("Tied must be a callable or set to False or None")
        self._tied = value

    @property
    def bounds(self):
        """The minimum and maximum values of a parameter as a tuple"""

        return self._bounds

    @bounds.setter
    def bounds(self, value):
        """Set the minimum and maximum values of a parameter from a tuple"""

        _min, _max = value
        if _min is not None:
            if not isinstance(_min, (numbers.Number, Quantity)):
                raise TypeError("Min value must be a number or a Quantity")
            if isinstance(_min, Quantity):
                _min = float(_min.value)
            else:
                _min = float(_min)

        if _max is not None:
            if not isinstance(_max, (numbers.Number, Quantity)):
                raise TypeError("Max value must be a number or a Quantity")
            if isinstance(_max, Quantity):
                _max = float(_max.value)
            else:
                _max = float(_max)

        self._bounds = (_min, _max)

    @property
    def min(self):
        """A value used as a lower bound when fitting a parameter"""

        return self.bounds[0]

    @min.setter
    def min(self, value):
        """Set a minimum value of a parameter"""

        self.bounds = (value, self.max)

    @property
    def max(self):
        """A value used as an upper bound when fitting a parameter"""

        return self.bounds[1]

    @max.setter
    def max(self, value):
        """Set a maximum value of a parameter."""

        self.bounds = (self.min, value)

    @property
    def validator(self):
        """
        Used as a decorator to set the validator method for a `Parameter`.
        The validator method validates any value set for that parameter.
        It takes two arguments--``self``, which refers to the `Model`
        instance (remember, this is a method defined on a `Model`), and
        the value being set for this parameter.  The validator method's
        return value is ignored, but it may raise an exception if the value
        set on the parameter is invalid (typically an `InputParameterError`
        should be raised, though this is not currently a requirement).

        """

        def validator(func, self=self):
            if callable(func):
                self._validator = func
                return self
            else:
                raise ValueError("This decorator method expects a callable.\n"
                                 "The use of this method as a direct validator is\n"
                                 "deprecated; use the new validate method instead\n")
        return validator

    def validate(self, value):
        """ Run the validator on this parameter"""
        if self._validator is not None and self._model is not None:
            self._validator(self._model, value)

    def copy(self, name=None, description=None, default=None, unit=None,
             getter=None, setter=None, fixed=False, tied=False, min=None,
             max=None, bounds=None, prior=None, posterior=None):
        """
        Make a copy of this `Parameter`, overriding any of its core attributes
        in the process (or an exact copy).

        The arguments to this method are the same as those for the `Parameter`
        initializer.  This simply returns a new `Parameter` instance with any
        or all of the attributes overridden, and so returns the equivalent of:

        .. code:: python

            Parameter(self.name, self.description, ...)

        """

        kwargs = locals().copy()
        del kwargs['self']

        for key, value in kwargs.items():
            if value is None:
                # Annoying special cases for min/max where are just aliases for
                # the components of bounds
                if key in ('min', 'max'):
                    continue
                else:
                    if hasattr(self, key):
                        value = getattr(self, key)
                    elif hasattr(self, '_' + key):
                        value = getattr(self, '_' + key)
                kwargs[key] = value

        return self.__class__(**kwargs)

    @property
    def model(self):
        """ Return the model this  parameter is associated with."""
        return self._model

    @model.setter
    def model(self, value):
        self._model = value
        self._setter = self._create_value_wrapper(self._setter, value)
        self._getter = self._create_value_wrapper(self._getter, value)
        if self._model_required:
            if self._default is not None:
                self.value = self._default
            else:
                self._value = None

    @property
    def _raw_value(self):
        """
        Currently for internal use only.

        Like Parameter.value but does not pass the result through
        Parameter.getter.  By design this should only be used from bound
        parameters.

        This will probably be removed are retweaked at some point in the
        process of rethinking how parameter values are stored/updated.
        """
        if self._setter:
            return self._internal_value
        return self.value

    def _create_value_wrapper(self, wrapper, model):
        """Wraps a getter/setter function to support optionally passing in
        a reference to the model object as the second argument.
        If a model is tied to this parameter and its getter/setter supports
        a second argument then this creates a partial function using the model
        instance as the second argument.
        """

        if isinstance(wrapper, np.ufunc):
            if wrapper.nin != 1:
                raise TypeError("A numpy.ufunc used for Parameter "
                                "getter/setter may only take one input "
                                "argument")
        elif wrapper is None:
            # Just allow non-wrappers to fall through silently, for convenience
            return None
        else:
            inputs, _ = get_inputs_and_params(wrapper)
            nargs = len(inputs)

            if nargs == 1:
                pass
            elif nargs == 2:
                self._model_required = True
                if model is not None:
                    # Don't make a partial function unless we're tied to a
                    # specific model instance
                    model_arg = inputs[1].name
                    wrapper = functools.partial(wrapper, **{model_arg: model})
            else:
                raise TypeError("Parameter getter/setter must be a function "
                                "of either one or two arguments")

        return wrapper

    def __array__(self, dtype=None):
        # Make np.asarray(self) work a little more straightforwardly
        arr = np.asarray(self.value, dtype=dtype)

        if self.unit is not None:
            arr = Quantity(arr, self.unit, copy=False)

        return arr

    def __bool__(self):
        return bool(np.all(self.value))

    __add__ = _binary_arithmetic_operation(operator.add)
    __radd__ = _binary_arithmetic_operation(operator.add, reflected=True)
    __sub__ = _binary_arithmetic_operation(operator.sub)
    __rsub__ = _binary_arithmetic_operation(operator.sub, reflected=True)
    __mul__ = _binary_arithmetic_operation(operator.mul)
    __rmul__ = _binary_arithmetic_operation(operator.mul, reflected=True)
    __pow__ = _binary_arithmetic_operation(operator.pow)
    __rpow__ = _binary_arithmetic_operation(operator.pow, reflected=True)
    __truediv__ = _binary_arithmetic_operation(operator.truediv)
    __rtruediv__ = _binary_arithmetic_operation(operator.truediv,
                                                reflected=True)
    __eq__ = _binary_comparison_operation(operator.eq)
    __ne__ = _binary_comparison_operation(operator.ne)
    __lt__ = _binary_comparison_operation(operator.lt)
    __gt__ = _binary_comparison_operation(operator.gt)
    __le__ = _binary_comparison_operation(operator.le)
    __ge__ = _binary_comparison_operation(operator.ge)
    __neg__ = _unary_arithmetic_operation(operator.neg)
    __abs__ = _unary_arithmetic_operation(operator.abs)


def param_repr_oneline(param):
    """
    Like array_repr_oneline but works on `Parameter` objects and supports
    rendering parameters with units like quantities.
    """

    out = array_repr_oneline(param.value)
    if param.unit is not None:
        out = f'{out} {param.unit!s}'
    return out
