cimport cython
import numpy as np
cimport numpy as np
from enum import Enum
from copy import deepcopy
import logging
import numbers
import re
import weakref
from collections import namedtuple
from finesse.element import ModelElement
from finesse.exceptions import (
    ComponentNotConnected, ParameterLocked, ModelParameterSelfReferenceError
)
from finesse.symbols import FUNCTIONS, Symbol, Resolving


LOGGER = logging.getLogger(__name__)
c_reference = re.compile(r"([a-zA-Z][a-zA-Z0-9_]*).([a-zA-Z][a-zA-Z0-9_]*)")


# Allowed datatypes for parameters
allowed_datatypes = (int, float, bool, Enum, np.int64, np.float64, np.bool_)


# struct for storing float_parameter information on each element
ParameterInfo = namedtuple(
    "ParameterInfo", (
        "name", "description", "dtype", "dtype_cast", "units", "is_geometric", "changeable_during_simulation")
)


class ParameterRef(Symbol):
    """
    A symbolic instance of a parameter in the model. A parameter
    is owned by some model element, which can be used to connect
    its value to other aspects of the simulation.
    """

    def __init__(self, param):
        self.__param = weakref.ref(param)
        self.__name = f"{self.owner.name}.{self.parameter.name}"
        self.__cyexpr_name = self.__name.replace(".", "_").lower().encode("UTF-8")

    @property
    def parameter(self):
        return self.__param()

    @property
    def name(self):
        return self.__name

    @property
    def cyexpr_name(self):
        """Name of the parameter reference in ``cyexpr`` compatibility
        format.

        This is equivalent to :attr:`.ParameterRef.name` but with ``"."``
        replaced with ``"_"``, converted to lower case and encoded
        in UTF-8 format as a ``bytes`` object.

        The above format makes this compatible with passing to the underlying
        math evaluator engine (``tinyexpr``) used via the :mod:`.cyexpr` sub-module.

        .. note::

            This should, typically, never need to be used outside of internal usage. It
            exists primarily to act as the owner for the parameter name strings (avoiding
            dangling pointers in the expression code).

        :getter: Returns the ``cyexpr`` compatible name format of the pref name (read-only).
        """
        return self.__cyexpr_name

    @property
    def owner(self):
        return self.parameter.component

    def __eq__(self, obj):
        if isinstance(obj, ParameterRef):
            if obj.parameter is self.parameter:
                return True
        elif isinstance(obj, Parameter):
            if obj is self.parameter:
                return True

    def __hash__(self):
        return hash(self.parameter)

    def __deepcopy__(self, memo):
        cls = self.__class__
        result = cls.__new__(cls)
        memo[id(self)] = result
        result.__dict__.update(deepcopy(self.__dict__, memo))
        owner_id = id(self.parameter.owner)
        new_model = memo.get(id(self.parameter.owner._model))

        # We need to update this reference later on
        # This will be called when the port property
        # is accessed. When this happens we'll peak back
        # at the memo once it has been filled and get
        # the new port reference. After this the refcount
        # for this function should goto zero and be garbage
        # collected
        def update_later():
            result.__param = weakref.ref(
                getattr(memo[owner_id], self.parameter.name)
            )

        result.__param = update_later  # just in case something calls this weakref in the meantime
        new_model.after_deepcopy.append(update_later)

        return result

    def eval(self, keep_changing_symbols=False, subs=None, **kwargs):
        p = self.parameter

        if keep_changing_symbols and p.is_changing:
            return self
        if subs is not None and self in subs:
            return subs[self]
        if hasattr(p.value, "eval"):
            return p.value.eval()
        return p.value


cdef class Parameter:
    def __init__(self, parameter_info, component):
        from finesse.element import ModelElement
        self.__value = None
        self.__cvalue = 0.0

        self.__units = parameter_info.units
        self.__name = parameter_info.name
        self.__datatype = parameter_info.dtype
        self.__datatype_cast = parameter_info.dtype_cast
        self.__full_name = f"{component.name}.{parameter_info.name}"
        self.__description = parameter_info.description
        self.__component = weakref.ref(component)
        self.__component_type = type(component)
        self.__changeable_during_simulation = parameter_info.changeable_during_simulation
        self._locked = False
        self.external_setters = []
        self.__is_tunable = False
        self.__update_state()
        self.is_geometric = False
        self.__validator = ModelElement._validators[self.__component_type][self.name]
        self.__resolving = False

    cdef __cdeepcopy__(self, Parameter new, dict memo):
        new.__units = self.__units
        new.__name = self.__name
        new.__datatype = self.__datatype
        new.__datatype_cast = self.__datatype_cast
        new.__full_name = self.__full_name
        new.__description = self.__description
        new._locked = self._locked
        new.external_setters = deepcopy(self.external_setters, memo)
        new.__is_tunable = self.__is_tunable
        new.state = self.state
        new.__value = deepcopy(self.__value, memo)
        new.__cvalue = self.__cvalue
        new.__component_type = self.__component_type
        new.__validator = self.__validator
        new.__changeable_during_simulation = self.__changeable_during_simulation

    def __deepcopy__(self, memo):
        new = Parameter.__new__(Parameter)
        memo[id(self)] = new
        self.__cdeepcopy__(new, memo)

        # Manually update the weakrefs to be correct
        id_component = id(self.component)

        if id_component not in memo:
            # We need to update this reference later on
            # This will be called when the port property
            # is accessed. When this happens we'll peak back
            # at the memo once it has been filled and get
            # the new port reference. After this the refcount
            # for this function should goto zero and be garbage
            # collected
            def update_later():
                new.__component = weakref.ref(memo[id(self.component)])

            new.__component = update_later  # just in case something calls
            # this weakref in the meantime
            memo[id(self.__component()._model)].after_deepcopy.append(update_later)
        else:
            new.__component = weakref.ref(memo[id(self.component)])

        return new

    cdef __update_state(self):
        if issubclass(type(self.__value), allowed_datatypes):
            self.state = ParameterState.Numeric
        elif self.__value is None:
            self.state = ParameterState.NONE
        elif isinstance(self.__value, Symbol):
            self.state = ParameterState.Symbolic
        elif callable(self.__value):
            self.state = ParameterState.Unresolved
        else:
            raise Exception(f"Unexpected parameter value '{self.__value}' ({type(self.__value))})")

    @property
    def units(self):
        if self.__units is None:
            return ""
        return self.__units

    @property
    def name(self):
        return self.__name

    @property
    def datatype(self):
        """The underlying C datatype of this parameter"""
        return self.__datatype

    def datatype_cast(self, value, ignore_failure=False):
        """Casts a value into the datatype of this parameter.
        If ignore_failure is True then if this value cannot
        be cast it is just returned.
        """
        try:
            return self.__datatype_cast(value)
        except (ValueError, TypeError) as ex:
            if ignore_failure:
                return value
            else:
                raise ex

    @property
    def component(self):
        """The component/element this parameter is associated with"""
        return self.__component()

    @property
    def owner(self):
        """The component/element this parameter is associated with"""
        return self.__component()

    @property
    def locked(self):
        """If locked, this parameters value cannot be changed"""
        return self._locked

    @property
    def changeable_during_simulation(self):
        """True if this parameter cannot be changed during a simulation"""
        return self.__changeable_during_simulation

    @property
    def _model(self):
        """
        Returns the model that this component is connected to. Raises an exception if
        it is not connected to anything to stop code accidently using unconnected
        components by accident.

        Returns
        -------
        :class:`.model.Model`

        Raises
        ------
        :class:`.exceptions.ComponentNotConnected`
        """
        if self.__component() is None:
            raise ComponentNotConnected("Lost weak reference")
        else:
            return self.__component()._model

    @property
    def full_name(self):
        return self.__full_name

    @property
    def description(self):
        return self.__description

    @property
    def ref(self):
        """Returns a reference to this parameter's value to be used in symbolic expressions."""
        return ParameterRef(self)

    cdef bint _is_changing(self):
        if self.state == ParameterState.Symbolic:
            return self.value.is_changing

        return self.__is_tunable

    @property
    def is_changing(self):
        """True if this parameter will be changing during a simulation"""
        return self._is_changing()

    @property
    def is_tunable(self):
        """True if this parameter will be directly changed during a simulation"""
        return self.__is_tunable

    @property
    def is_symbolic(self):
        """True if this parameter's value is symbolic"""
        return self.state == ParameterState.Symbolic

    @is_tunable.setter
    def is_tunable(self, bint value):
        if not self.changeable_during_simulation:
            raise Exception(f"The property {self.full_name} cannot be changed during a simulation")
        if self._locked:
            raise ParameterLocked()
        if len(self.external_setters) > 0:
            raise Exception(f"{repr(self)} is being externally controlled by {self.external_setters}, so it cannot be tuned explicitly.")
        if self.state == ParameterState.Symbolic:
            raise Exception(
                f"Tunable state of {repr(self)} cannot be set to True when its value is symbolic."
            )

        self.__is_tunable = value

    def resolve(self):
        """When this parameters value has some dependency whose value has not yet been set,
        like during parsing, its value will be a callable object. This method will call this
        function to return the value.
        """
        self.__resolving = True

        if callable(self.value):
            self.value = self.value(self.component._model)

        self.__resolving = False
        self.__update_state()

    def lambdify(self, *args):
        """Returns a lambda function that returns the value of this parameter.

        Parameters in a symbolic function can be kept as variables by passing
        the Parameter object as optional arguments. The returned lambda function
        will then have len(args) arguments - effectively subsituting values at
        call time.
        """
        if self.state == ParameterState.Symbolic:
            refs = {**self.component._model.elements, **FUNCTIONS}
            sym_str = str(self.value)
            ARGS = []
            for i, arg in enumerate(args):
                ARGS.append(arg.full_name.replace('.','__'))
                sym_str = c_reference.sub(ARGS[-1], sym_str)

            return eval(f"lambda {','.join(ARGS)}: {sym_str}", refs)
        else:
            # if len(args) > 0:
            #     raise Exception("Can't specify symbolic arguments to lambdify if this parameter isn't symbolic.")
            return lambda *x: self.value

    def eval(self, bint keep_changing_symbols=False):
        """
        Evaluates the value of this parameter. If the parameter
        is dependant on some symbolic statement this will evaluate
        that. If it is not the value itself is returned. This method
        should be used when filling in matrices for computing solutions
        of a model.
        """
        if self.state == ParameterState.Unresolved:
            self.resolve()

        if self.state == ParameterState.Symbolic:
            y = self.value.eval(keep_changing_symbols=keep_changing_symbols)
            if self.__validator is None:
                return y
            else:
                return self.__validator(self.owner, y)

        else:
            return self.value

    cdef _get_value(self):
        return self.__value

    @property
    def value(self):
        return self._get_value()

    def set_external_setter(self, element, symbol):
        """Sets an element as an external controller of the value of this parameter.
        It is used in cases where an element is physically imposing its value onto this
        one, such as degrees of freedom.

        Parameters
        ----------
        element : ModelElement
            Element that will be controlling this parameter

        symbol : symbolic expression
            The expression that this element is imposing upon the parameter.
        """
        if element in self.external_setters:
            raise Exception(f"{repr(element)} is already an external setter of {repr(self)}")
        self.external_setters.append(element)
        self._set_value(self.value + symbol)

    def remove_external_setter(self, element, symbol):
        """Stops an element from being an external setter.

        Parameters
        ----------
        element : ModelElement
            Element that is controlling this parameter
        symbol : symbolic expression
            The symbolic expression that was added to this parameters value, this will
            be subtracted.
        """
        if element not in self.external_setters:
            raise Exception(f"{repr(element)} is not an external setter of {repr(self)}")
        self.external_setters.remove(element)
        # Use sympy to simplify the expression to remove the symbol
        self._set_value((self.value - symbol).sympy_simplify())

    def _reset_cvalue(self):
        """Resets the C-level value. To be used in clean-up stage of an action."""
        self.__cvalue =  self.__datatype_cast(self.eval()) # cast to correct datatype

    cdef _set_value(self, value):
        from finesse.element import ModelElement
        setting_fn = ModelElement._setters[type(self.owner)].get(self.name)
        cdef bint is_symbol = isinstance(value, Symbol)
        cdef bint is_callable = isinstance(value, Resolving)

        if setting_fn is not None and not self.__resolving and not is_symbol:
            handle = setting_fn(self.owner)
            if not handle.has_been_called:
                oname = self.owner.name
                LOGGER.warning(
                    "Direct setting of %s.%s is not recommended. You should "
                    "use %s.%s to set this parameter.",
                    oname,
                    self.name,
                    oname,
                    handle.__name__,
                )

        if self.locked:
            if self.state == ParameterState.Symbolic:
                raise ParameterLocked(f"Parameter {repr(self)} is symbolic so value cannot be directly set")
            else:
                raise ParameterLocked(f"Parameter {repr(self)} is locked")

        if is_symbol:
            for p in value.parameters():
                if p == self.ref:
                    raise ModelParameterSelfReferenceError(value, self)

        if self.__is_tunable and self.state == ParameterState.Symbolic:
            LOGGER.warn(
                f"{repr(self)}: Setting `is_tunable` to False as new "
                "value is symbolic, is_tunable was previously True."
            )
            self.__is_tunable = False

        if is_symbol or value is None or is_callable:
            # Don't cast here as need to keep original object
            self.__value = value
        elif self.__validator is None:
            self.__value = self.__datatype_cast(value)
        else:
            self.__value = self.__validator(self.owner, self.__datatype_cast(value))

        # Correspondingly set the low-level __cvalue as long as value
        # is valid and not in an unresolved state
        if value is not None and not is_callable:
            try:
                self.__cvalue =  self.__datatype_cast(self.__value)
            # Return of self.eval is allowed to be None, unfortunately, so
            # take care of that here (any other type error would invariably
            # show up earlier than this anyway)
            except TypeError:
                pass

        self.__update_state()

    @value.setter
    def value(self, value):
        if len(self.external_setters) > 0:
            raise Exception(f"Model parameter {repr(self)} is being externally controlled by {self.external_setters}")

        self._set_value(value)

    cdef int set_double_value(self, double value) except -1:
        """
        UNSAFE!
        Only use when you know the parameter should be changing.
        """
        # TODO (sjr) Not sure setting of self.__value here is reqd
        #            anymore as just setting __cvalue should suffice
        #            here now (as this method should only be called
        #            during simulations anyway)
        if self.__validator is None:
            self.__value = value
        else:
            self.__value = self.__validator(self.owner, value)

        self.__cvalue = value

        return 0

    def _set(self, value):
        self.value = value

    def __str__(self):
        units = self.units
        if units:
            units = " " + units
        return f"{self.value}{units}"

    def __repr__(self):
        if self.__component() is None:
            return f"<LOST WEAKREF.{self.name}={str(self.value)}>"
        else:
            return f"<{self.__component().name}.{self.name}={str(self.value)} @ {hex(id(self))}>"

    def __pow__(self, power, order):
        return self.value ** power

    def __mul__(A, B):
        if isinstance(A, Parameter):
            return A.value * B
        else:
            return A * B.value

    def __add__(A, B):
        if isinstance(A, Parameter):
            return A.value + B
        else:
            return A + B.value

    def __sub__(A, B):
        if isinstance(A, Parameter):
            return A.value - B
        else:
            return A - B.value

    def __pos__(self):
        return self

    def __neg__(self):
        return -1 * self

    def __floordiv__(A, B):
        if isinstance(A, Parameter):
            return A.value // B
        else:
            return A // B.value

    def __truediv__(A, B):
        if isinstance(A, Parameter):
            return A.value / B
        else:
            return A / B.value

    def __complex__(self):
        return complex(self.value)

    def __float__(self):
        return float(self.value)

    def __int__(self):
        return int(self.value)

    def __hash__(self):
        return id(self)

    def __eq__(A, B):
        if hasattr(A, "value"):
            if hasattr(B, "value"):
                return A.value == B.value
            return A.value == B
        return A == B.value

    def __ge__(A, B):
        if hasattr(A, "value"):
            if hasattr(B, "value"):
                return A.value >= B.value
            return A.value >= B
        return A >= B.value

    def __gt__(A, B):
        if hasattr(A, "value"):
            if hasattr(B, "value"):
                return A.value > B.value
            return A.value > B
        return A > B.value

    def __le__(A, B):
        if hasattr(A, "value"):
            if hasattr(B, "value"):
                return A.value <= B.value
            return A.value <= B
        return A <= B.value

    def __lt__(A, B):
        if hasattr(A, "value"):
            if hasattr(B, "value"):
                return A.value < B.value
            return A.value < B
        return A < B.value


cdef class GeometricParameter(Parameter):
    """Specialised parameter class for variables which are dependencies
    of ABCD matrices. These include surface radii of curvature, lens focal
    lengths, beamsplitter angles of incidence, space lengths and space
    refractive indices.

    When setting the value of a GeometricParameter *outside of a simulation*, the dependent ABCD
    matrices get updated via the `update_abcd_matrices` C method. Inside a simulation,
    the ABCD matrix elements are updated in a much more efficient way via the connector
    workspaces.
    """
    def __init__(self, parameter_info, component):
        super().__init__(parameter_info, component)
        self.is_geometric = True
        self.is_nr = self.name == "nr"

    def __deepcopy__(self, memo):
        new = GeometricParameter.__new__(GeometricParameter)
        memo[id(self)] = new
        self.__cdeepcopy__(new, memo)

        # Manually update the weakrefs to be correct
        id_component = id(self.component)

        if id_component not in memo:
            # We need to update this reference later on
            # This will be called when the port property
            # is accessed. When this happens we'll peak back
            # at the memo once it has been filled and get
            # the new port reference. After this the refcount
            # for this function should goto zero and be garbage
            # collected
            def update_later():
                new.__component = weakref.ref(memo[id(self.component)])

            new.__component = update_later  # just in case something calls
            # this weakref in the meantime
            memo[id(self.__component()._model)].after_deepcopy.append(update_later)
        else:
            new.__component = weakref.ref(memo[id(self.component)])

        return new

    @cython.boundscheck(False)
    @cython.wraparound(False)
    @cython.initializedcheck(False)
    cdef void update_abcd_matrices(self):
        """Method for updating dependent ABCD matrices of the parameter *outside*
        of a simulation."""
        cdef:
            dict abcd_matrices = self.component._abcd_matrices
            list matrix_handles = list(abcd_matrices.values())

            object full_symbolic # Full symbolic ABCD matrix
            double[:, ::1] M_num # Numeric ABCD matrix

            object[:, ::1] M_sym # Actual symbolic matrix to use

        if self.is_nr: # Parameter is a refractive index
            from finesse.components import Surface
            if self.component.portA is not None:
                p1_comp = self.component.portA.component
            else:
                p1_comp = None
            if self.component.portB is not None:
                p2_comp = self.component.portB.component
            else:
                p2_comp = None
            # and if either / both ports are associated with Surfaces
            # then append their ABCD matrices so that they get updated
            # too (as some of them depend on nr of attached space)
            if isinstance(p1_comp, Surface):
                matrix_handles.extend(list(p1_comp._abcd_matrices.values()))
            if isinstance(p2_comp, Surface):
                matrix_handles.extend(list(p2_comp._abcd_matrices.values()))

        cdef Py_ssize_t i, j, k
        cdef Py_ssize_t N = len(matrix_handles)
        for i in range(N):
            full_symbolic = matrix_handles[i][0]
            # Total reflection means no symbolic matrix is formed
            # so ignore it and move on
            if full_symbolic is None:
                continue

            M_sym = full_symbolic
            M_num = matrix_handles[i][1]

            for j in range(2):
                for k in range(2):
                    M_num[j][k] = float(M_sym[j][k])

    # NOTE (sjr) ABCD matrices are updated using cyexpr's in the relevant
    #            workspace during a simulation, so we don't override
    #            set_double_value here. The update_abcd_matrices method
    #            is only called when using the value setter, which will be
    #            used outside of a simulation context.

    @property
    def value(self):
        return Parameter._get_value(self)

    @value.setter
    def value(self, value):
        Parameter._set_value(self, value)
        self.update_abcd_matrices()


class parameterproperty(property):
    """
    Descriptor class for declaring a simulation parameter. A simulation parameter
    is one that can be changed during a simulation and affect the resulting outputs.
    The idea is that output dependant variables should be marked as having been changed
    or will be changed during a simulation run. This allows us to then optimise parts of
    the model, as we can determine what will or will not be changing. This descriptor is
    paired with the :class:Parameter.

    Parameters can then be superficially locked once a model has been built so accidentally
    changing some parameter that isn't expected to change can flag a warning.
    """

    def __init__(self, fget, fset, flocked, doc=None):
        super().__init__(fget, fset, doc=doc)

        self.fget.__doc__ = ":getter: " + doc
        self.fset.__doc__ = ":setter: " + doc
        self.flocked = flocked
        self.flocked.__doc__ = "Parameter locking function for " + doc

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        else:
            return self.fget(obj)

    def __set__(self, obj, value):
        if self.flocked(obj):
            raise ParameterLocked()
        else:
            self.fset(obj, value)

    def __delete__(self, obj):
        raise AttributeError("can't delete parameter")

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)


def float_parameter(
    name, description, units=None, validate=None, setter=None,
    is_default=False, is_geometric=False, changeable_during_simulation=True
):
    """A parameter of an element whose value is decribed by a 64-bit floating point number"""
    return model_parameter(
        name, description, float, float,
        units=units, validate=validate, setter=setter,
        is_default=is_default, is_geometric=is_geometric,
        changeable_during_simulation=changeable_during_simulation
    )


def int_parameter(
    name, description, units=None, validate=None, setter=None,
    is_default=False, is_geometric=False, changeable_during_simulation=True
):
    """A parameter of an element whose value is decribed by a 64-bit integer number"""
    return model_parameter(
        name, description, int, int,
        units=units, validate=validate, setter=setter,
        is_default=is_default, is_geometric=is_geometric,
        changeable_during_simulation=changeable_during_simulation
    )


def enum_parameter(
    name, description, enum, units=None, validate=None, setter=None,
    is_default=False, is_geometric=False, changeable_during_simulation=True
):
    """A parameter of an element whose value is decribed by a Enum definition.
    Enum must only use integer values to describe its members. Unlike a general
    python Enum which can use strings.
    """
    if not issubclass(enum, Enum):
        raise Exception("enum argument should be an enum.Enum type object")

    for _ in enum.__members__.values():
        if type(_.value) is not int:
            raise Exception(f"Value for item {repr(_)} in {enum} must be an integer")

    # enums are irritating as they don't act like normal types. You need
    # to getitem to go from value to key. They also don't cast themselves
    # back to themselves as float(1.0) would.
    def enum_cast(value):
        if type(value) is enum:
            return value
        else:
            return enum[value]

    return model_parameter(
        name, description, int, enum_cast,
        units=units, validate=validate, setter=setter,
        is_default=is_default, is_geometric=is_geometric,
        changeable_during_simulation=changeable_during_simulation
    )


def bool_parameter(
    name, description, units=None, validate=None, setter=None,
    is_default=False, is_geometric=False, changeable_during_simulation=True
):
    """A parameter of an element whose value is decribed by a True or False value"""
    return model_parameter(
        name, description, bool, bool,
        units=units, validate=validate, setter=setter,
        is_default=is_default, is_geometric=is_geometric,
        changeable_during_simulation=changeable_during_simulation
    )


cdef object model_parameter(
    str name, str description, type _type, object _type_cast, str units, str validate, str setter,
    bint is_default, bint is_geometric, bint changeable_during_simulation
):
    """Decorator to register a model parameter with a double datatype field in the class.
    This shouldn't be called by users. Use the [type]_parameter functions above instead.
    """
    if not issubclass(_type, allowed_datatypes):
        raise Exception(f"Data type {_type} not allowed ({allowed_datatypes})")

    if validate is None:
        vld = None
    else:
        vld = lambda x, v: getattr(x, validate)(v)

    # setter is the name of a method which will be the only method that can
    # be used to set the parameter (direct setting will be forbidden)
    if setter is None:
        st = None
    else:
        st = lambda x: getattr(x, setter)

    def func(cls):
        p = parameterproperty(
            lambda x: getattr(x, f"__param_{name}"),
            lambda x, v: getattr(x, f"__param_{name}")._set(v),
            lambda x: getattr(x, f"__param_{name}").locked,
            doc=description
        )
        cls._param_dict[cls].append(
            ParameterInfo(
                name, description, _type, _type_cast, units, is_geometric, changeable_during_simulation
            )
        )
        cls._validators[cls][name] = vld
        cls._setters[cls][name] = st

        if is_default:
            if cls in cls._default_parameter_name:
                raise ValueError(
                    f"is_default cannot be set for more than one model parameter on {cls!r}"
                )

            cls._default_parameter_name[cls] = name

        setattr(cls, name, p)
        return cls

    return func


def info_parameter(name, description, units=None):
    """Decorator to register an info parameter field in the class.

    Info parameters are purely informative properties, and cannot be directly scanned using an axis.
    """
    def func(cls):
        cls._info_param_dict[cls][name] = (description, units)
        return cls

    return func
