"""
Multi-Signals
=============

Define multi-continuous signal support for colour science computations.

This module provides the :class:`colour.continuous.MultiSignals` class for
representing and operating on multiple continuous signals simultaneously,
supporting interpolation and extrapolation operations.

-   :class:`colour.continuous.MultiSignals`
"""

from __future__ import annotations

import typing
from collections.abc import Iterator, KeysView, Mapping, ValuesView

import numpy as np

from colour.constants import DTYPE_FLOAT_DEFAULT
from colour.continuous import AbstractContinuousFunction, Signal

if typing.TYPE_CHECKING:
    from colour.hints import (
        Any,
        Dict,
        DTypeFloat,
        List,
        Literal,
        NDArrayFloat,
        ProtocolExtrapolator,
        ProtocolInterpolator,
        Real,
        Self,
        Sequence,
        Type,
    )

from colour.hints import ArrayLike, Callable, Sequence, cast
from colour.utilities import (
    as_float_array,
    attest,
    first_item,
    int_digest,
    is_iterable,
    is_pandas_installed,
    multiline_repr,
    optional,
    required,
    tsplit,
    tstack,
    validate_method,
)
from colour.utilities.documentation import is_documentation_building

if typing.TYPE_CHECKING or is_pandas_installed():
    from pandas import DataFrame, Series  # pragma: no cover
else:  # pragma: no cover
    from unittest import mock

    DataFrame = mock.MagicMock()
    Series = mock.MagicMock()

__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 Colour Developers"
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"

__all__ = [
    "MultiSignals",
]


class MultiSignals(AbstractContinuousFunction):
    """
    Define the base class for multi-signals, a container for
    multiple :class:`colour.continuous.Signal` sub-class instances.

    .. important::

        Specific documentation about getting, setting, indexing and slicing
        the multi-signals values is available in the
        :ref:`spectral-representation-and-continuous-signal` section.

    Parameters
    ----------
    data
        Data to be stored in the multi-signals.
    domain
        Values to initialise the multiple :class:`colour.continuous.Signal`
        sub-class instances :attr:`colour.continuous.Signal.domain`
        attribute with. If both ``data`` and ``domain`` arguments are
        defined, the latter will be used to initialise the
        :attr:`colour.continuous.Signal.domain` attribute.
    labels
        Names to use for the :class:`colour.continuous.Signal` sub-class
        instances.

    Other Parameters
    ----------------
    dtype
        Floating point data type.
    extrapolator
        Extrapolator class type to use as extrapolating function for the
        :class:`colour.continuous.Signal` sub-class instances.
    extrapolator_kwargs
        Arguments to use when instantiating the extrapolating function of
        the :class:`colour.continuous.Signal` sub-class instances.
    interpolator
        Interpolator class type to use as interpolating function for the
        :class:`colour.continuous.Signal` sub-class instances.
    interpolator_kwargs
        Arguments to use when instantiating the interpolating function of
        the :class:`colour.continuous.Signal` sub-class instances.
    name
        Multi-signals name.
    signal_type
        The :class:`colour.continuous.Signal` sub-class type used for
        instances.

    Attributes
    ----------
    -   :attr:`~colour.continuous.MultiSignals.dtype`
    -   :attr:`~colour.continuous.MultiSignals.domain`
    -   :attr:`~colour.continuous.MultiSignals.range`
    -   :attr:`~colour.continuous.MultiSignals.interpolator`
    -   :attr:`~colour.continuous.MultiSignals.interpolator_kwargs`
    -   :attr:`~colour.continuous.MultiSignals.extrapolator`
    -   :attr:`~colour.continuous.MultiSignals.extrapolator_kwargs`
    -   :attr:`~colour.continuous.MultiSignals.function`
    -   :attr:`~colour.continuous.MultiSignals.signals`
    -   :attr:`~colour.continuous.MultiSignals.labels`
    -   :attr:`~colour.continuous.MultiSignals.signal_type`

    Methods
    -------
    -   :meth:`~colour.continuous.MultiSignals.__init__`
    -   :meth:`~colour.continuous.MultiSignals.__str__`
    -   :meth:`~colour.continuous.MultiSignals.__repr__`
    -   :meth:`~colour.continuous.MultiSignals.__hash__`
    -   :meth:`~colour.continuous.MultiSignals.__getitem__`
    -   :meth:`~colour.continuous.MultiSignals.__setitem__`
    -   :meth:`~colour.continuous.MultiSignals.__contains__`
    -   :meth:`~colour.continuous.MultiSignals.__eq__`
    -   :meth:`~colour.continuous.MultiSignals.__ne__`
    -   :meth:`~colour.continuous.MultiSignals.arithmetical_operation`
    -   :meth:`~colour.continuous.MultiSignals.multi_signals_unpack_data`
    -   :meth:`~colour.continuous.MultiSignals.fill_nan`
    -   :meth:`~colour.continuous.MultiSignals.to_dataframe`

    Examples
    --------
    Instantiation with implicit *domain* and a single signal:

    >>> range_ = np.linspace(10, 100, 10)
    >>> print(MultiSignals(range_))
    [[   0.   10.]
     [   1.   20.]
     [   2.   30.]
     [   3.   40.]
     [   4.   50.]
     [   5.   60.]
     [   6.   70.]
     [   7.   80.]
     [   8.   90.]
     [   9.  100.]]

    Instantiation with explicit *domain* and a single signal:

    >>> domain = np.arange(100, 1100, 100)
    >>> print(MultiSignals(range_, domain))
    [[  100.    10.]
     [  200.    20.]
     [  300.    30.]
     [  400.    40.]
     [  500.    50.]
     [  600.    60.]
     [  700.    70.]
     [  800.    80.]
     [  900.    90.]
     [ 1000.   100.]]

    Instantiation with multiple signals:

    >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
    >>> range_ += np.array([0, 10, 20])
    >>> print(MultiSignals(range_, domain))
    [[  100.    10.    20.    30.]
     [  200.    20.    30.    40.]
     [  300.    30.    40.    50.]
     [  400.    40.    50.    60.]
     [  500.    50.    60.    70.]
     [  600.    60.    70.    80.]
     [  700.    70.    80.    90.]
     [  800.    80.    90.   100.]
     [  900.    90.   100.   110.]
     [ 1000.   100.   110.   120.]]

    Instantiation with a *dict*:

    >>> print(MultiSignals(dict(zip(domain, range_))))
    [[  100.    10.    20.    30.]
     [  200.    20.    30.    40.]
     [  300.    30.    40.    50.]
     [  400.    40.    50.    60.]
     [  500.    50.    60.    70.]
     [  600.    60.    70.    80.]
     [  700.    70.    80.    90.]
     [  800.    80.    90.   100.]
     [  900.    90.   100.   110.]
     [ 1000.   100.   110.   120.]]

    Instantiation using a *Signal* sub-class:

    >>> class NotSignal(Signal):
    ...     pass

    >>> multi_signals = MultiSignals(range_, domain, signal_type=NotSignal)
    >>> print(multi_signals)
    [[  100.    10.    20.    30.]
     [  200.    20.    30.    40.]
     [  300.    30.    40.    50.]
     [  400.    40.    50.    60.]
     [  500.    50.    60.    70.]
     [  600.    60.    70.    80.]
     [  700.    70.    80.    90.]
     [  800.    80.    90.   100.]
     [  900.    90.   100.   110.]
     [ 1000.   100.   110.   120.]]
     >>> type(multi_signals.signals[0])  # doctest: +SKIP
     <class 'multi_signals.NotSignal'>

    Instantiation with a *Pandas* `Series`:

    >>> if is_pandas_installed():
    ...     from pandas import Series
    ...
    ...     print(
    ...         MultiSignals(  # doctest: +SKIP
    ...             Series(dict(zip(domain, np.linspace(10, 100, 10))))
    ...         )
    ...     )
    [[  100.    10.]
     [  200.    20.]
     [  300.    30.]
     [  400.    40.]
     [  500.    50.]
     [  600.    60.]
     [  700.    70.]
     [  800.    80.]
     [  900.    90.]
     [ 1000.   100.]]

    Instantiation with a *Pandas* :class:`pandas.DataFrame`:

    >>> if is_pandas_installed():
    ...     from pandas import DataFrame
    ...
    ...     data = dict(zip(["a", "b", "c"], tsplit(range_)))
    ...     print(MultiSignals(DataFrame(data, domain)))  # doctest: +SKIP
    [[  100.    10.    20.    30.]
     [  200.    20.    30.    40.]
     [  300.    30.    40.    50.]
     [  400.    40.    50.    60.]
     [  500.    50.    60.    70.]
     [  600.    60.    70.    80.]
     [  700.    70.    80.    90.]
     [  800.    80.    90.   100.]
     [  900.    90.   100.   110.]
     [ 1000.   100.   110.   120.]]

    Retrieving domain *y* variable for arbitrary range *x* variable:

    >>> x = 150
    >>> range_ = tstack([np.sin(np.linspace(0, 1, 10))] * 3)
    >>> range_ += np.array([0.0, 0.25, 0.5])
    >>> MultiSignals(range_, domain)[x]  # doctest: +ELLIPSIS
    array([ 0.0359701...,  0.2845447...,  0.5331193...])
    >>> x = np.linspace(100, 1000, 3)
    >>> MultiSignals(range_, domain)[x]  # doctest: +ELLIPSIS
    array([[  4.4085384...e-20,   2.5000000...e-01,   5.0000000...e-01],
           [  4.7669395...e-01,   7.2526859...e-01,   9.7384323...e-01],
           [  8.4147098...e-01,   1.0914709...e+00,   1.3414709...e+00]])

    Using an alternative interpolating function:

    >>> x = 150
    >>> from colour.algebra import CubicSplineInterpolator
    >>> MultiSignals(range_, domain, interpolator=CubicSplineInterpolator)[
    ...     x
    ... ]  # doctest: +ELLIPSIS
    array([ 0.0555274...,  0.3055274...,  0.5555274...])
    >>> x = np.linspace(100, 1000, 3)
    >>> MultiSignals(range_, domain, interpolator=CubicSplineInterpolator)[
    ...     x
    ... ]  # doctest: +ELLIPSIS
    array([[ 0.       ...,  0.25     ...,  0.5      ...],
           [ 0.4794253...,  0.7294253...,  0.9794253...],
           [ 0.8414709...,  1.0914709...,  1.3414709...]])
    """

    def __init__(
        self,
        data: (
            ArrayLike
            | DataFrame
            | dict
            | Self
            | Sequence
            | Series
            | Signal
            | ValuesView
            | None
        ) = None,
        domain: ArrayLike | KeysView | None = None,
        labels: Sequence | None = None,
        **kwargs: Any,
    ) -> None:
        super().__init__(kwargs.get("name"))

        self._signal_type: Type[Signal] = kwargs.get("signal_type", Signal)

        self._signals: Dict[str, Signal] = self.multi_signals_unpack_data(
            data, domain, labels, **kwargs
        )

    @property
    def dtype(self) -> Type[DTypeFloat]:
        """
        Getter and setter for the multi-signals dtype.

        Parameters
        ----------
        value
            Value to set the multi-signals dtype with.

        Returns
        -------
        Type[DTypeFloat]
            Multi-signals dtype.
        """

        return first_item(self._signals.values()).dtype

    @dtype.setter
    def dtype(self, value: Type[DTypeFloat]) -> None:
        """Setter for the **self.dtype** property."""

        for signal in self._signals.values():
            signal.dtype = value

    @property
    def domain(self) -> NDArrayFloat:
        """
        Getter and setter for the multi-signals' independent
        domain variable :math:`x`.

        Parameters
        ----------
        value
            Value to set the multi-signals independent domain
            variable :math:`x` with.

        Returns
        -------
        :class:`numpy.ndarray`
            Multi-signals independent domain variable
            :math:`x`.
        """

        return first_item(self._signals.values()).domain

    @domain.setter
    def domain(self, value: ArrayLike) -> None:
        """Setter for the **self.domain** property."""

        for signal in self._signals.values():
            signal.domain = as_float_array(value, self.dtype)

    @property
    def range(self) -> NDArrayFloat:
        """
        Getter and setter for the multi-signals' range
        variable :math:`y`.

        Parameters
        ----------
        value
            Value to set the multi-signals' range variable
            :math:`y` with.

        Returns
        -------
        :class:`numpy.ndarray`
            Multi-signals' range variable :math:`y`.
        """

        return tstack([signal.range for signal in self._signals.values()])

    @range.setter
    def range(self, value: ArrayLike) -> None:
        """Setter for the **self.range** property."""

        value = as_float_array(value)

        if value.ndim in (0, 1):
            for signal in self._signals.values():
                signal.range = value
        else:
            attest(
                value.shape[-1] == len(self._signals),
                'Corresponding "y" variable columns must have '
                'same count than underlying "Signal" components!',
            )

            for signal, y in zip(self._signals.values(), tsplit(value), strict=True):
                signal.range = y

    @property
    def interpolator(self) -> Type[ProtocolInterpolator]:
        """
        Getter and setter for the multi-signals interpolator
        type.

        Parameters
        ----------
        value
            Value to set the multi-signals interpolator type
            with.

        Returns
        -------
        Type[ProtocolInterpolator]
            Multi-signals interpolator type.
        """

        return first_item(self._signals.values()).interpolator

    @interpolator.setter
    def interpolator(self, value: Type[ProtocolInterpolator]) -> None:
        """Setter for the **self.interpolator** property."""

        if value is not None:
            for signal in self._signals.values():
                signal.interpolator = value

    @property
    def interpolator_kwargs(self) -> dict:
        """
        Getter and setter for the interpolator instantiation time arguments.

        Parameters
        ----------
        value
            Value to set the multi-signals interpolator
            instantiation time arguments to.

        Returns
        -------
        :class:`dict`
            Multi-signals interpolator instantiation time
            arguments.
        """

        return first_item(self._signals.values()).interpolator_kwargs

    @interpolator_kwargs.setter
    def interpolator_kwargs(self, value: dict) -> None:
        """Setter for the **self.interpolator_kwargs** property."""

        for signal in self._signals.values():
            signal.interpolator_kwargs = value

    @property
    def extrapolator(self) -> Type[ProtocolExtrapolator]:
        """
        Getter and setter for the multi-signals extrapolator
        type.

        Parameters
        ----------
        value
            Value to set the multi-signals extrapolator type
            with.

        Returns
        -------
        Type[ProtocolExtrapolator]
            Multi-signals extrapolator type.
        """

        return first_item(self._signals.values()).extrapolator

    @extrapolator.setter
    def extrapolator(self, value: Type[ProtocolExtrapolator]) -> None:
        """Setter for the **self.extrapolator** property."""

        for signal in self._signals.values():
            signal.extrapolator = value

    @property
    def extrapolator_kwargs(self) -> dict:
        """
        Getter and setter for the multi-signals extrapolator
        instantiation time arguments.

        Parameters
        ----------
        value
            Value to set the multi-signals extrapolator
            instantiation time arguments to.

        Returns
        -------
        :class:`dict`
            Multi-signals extrapolator instantiation time
            arguments.
        """

        return first_item(self._signals.values()).extrapolator_kwargs

    @extrapolator_kwargs.setter
    def extrapolator_kwargs(self, value: dict) -> None:
        """Setter for the **self.extrapolator_kwargs** property."""

        for signal in self._signals.values():
            signal.extrapolator_kwargs = value

    @property
    def function(self) -> Callable:
        """
        Getter for the multi-signals callable.

        Returns
        -------
        Callable
            Multi-signals callable.
        """

        return first_item(self._signals.values()).function

    @property
    def signals(self) -> Dict[str, Signal]:
        """
        Getter and setter for the dictionary of
        :class:`colour.continuous.Signal` sub-class instances.

        Parameters
        ----------
        value
            Dictionary of :class:`colour.continuous.Signal` sub-class
            instances to set.

        Returns
        -------
        :class:`dict`
            Dictionary mapping signal names to their corresponding
            :class:`colour.continuous.Signal` sub-class instances.
        """

        return self._signals

    @signals.setter
    def signals(
        self,
        value: ArrayLike | DataFrame | dict | Self | Series | Signal | None,
    ) -> None:
        """Setter for the **self.signals** property."""

        self._signals = self.multi_signals_unpack_data(
            value, signal_type=self._signal_type
        )

    @property
    def labels(self) -> List[str]:
        """
        Getter and setter for the :class:`colour.continuous.Signal` sub-class
        instance names.

        Parameters
        ----------
        value
            Value to set the :class:`colour.continuous.Signal` sub-class
            instance names.

        Returns
        -------
        :class:`list`
            :class:`colour.continuous.Signal` sub-class instance names.
        """

        return [str(key) for key in self._signals]

    @labels.setter
    def labels(self, value: Sequence) -> None:
        """Setter for the **self.labels** property."""

        attest(
            is_iterable(value),
            f'"labels" property: "{value}" is not an "iterable" like object!',
        )

        attest(
            len(set(value)) == len(value),
            '"labels" property: values must be unique!',
        )

        attest(
            len(value) == len(self.labels),
            f'"labels" property: length must be "{len(self._signals)}"!',
        )

        self._signals = {
            str(value[i]): signal for i, signal in enumerate(self._signals.values())
        }

    @property
    def signal_type(self) -> Type[Signal]:
        """
        Getter for the type of :class:`colour.continuous.Signal`
        sub-class instances.

        Returns
        -------
        Type[Signal]
            Type of :class:`colour.continuous.Signal` sub-class
            instances used in this multi-signal collection.
        """

        return self._signal_type

    def __str__(self) -> str:
        """
        Return a formatted string representation of the multi-signals.

        Returns
        -------
        :class:`str`
            Formatted string representation.

        Examples
        --------
        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> print(MultiSignals(range_))
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        """

        return str(np.hstack([self.domain[:, None], self.range]))

    def __repr__(self) -> str:
        """
        Return an evaluable string representation of the multi-signals.

        Returns
        -------
        :class:`str`
            Evaluable string representation.

        Examples
        --------
        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> MultiSignals(range_)
        MultiSignals([[   0.,   10.,   20.,   30.],
                      [   1.,   20.,   30.,   40.],
                      [   2.,   30.,   40.,   50.],
                      [   3.,   40.,   50.,   60.],
                      [   4.,   50.,   60.,   70.],
                      [   5.,   60.,   70.,   80.],
                      [   6.,   70.,   80.,   90.],
                      [   7.,   80.,   90.,  100.],
                      [   8.,   90.,  100.,  110.],
                      [   9.,  100.,  110.,  120.]],
                     ['0', '1', '2'],
                     KernelInterpolator,
                     {},
                     Extrapolator,
                     {'method': 'Constant', 'left': nan, 'right': nan})
        """

        if is_documentation_building():  # pragma: no cover
            return f"{self.__class__.__name__}(name='{self.name}', ...)"

        return multiline_repr(
            self,
            [
                {
                    "formatter": lambda x: repr(  # noqa: ARG005
                        np.hstack([self.domain[:, None], self.range])
                    ),
                },
                {"name": "labels"},
                {
                    "name": "interpolator",
                    "formatter": lambda x: (  # noqa: ARG005
                        self.interpolator.__name__
                    ),
                },
                {"name": "interpolator_kwargs"},
                {
                    "name": "extrapolator",
                    "formatter": lambda x: (  # noqa: ARG005
                        self.extrapolator.__name__
                    ),
                },
                {"name": "extrapolator_kwargs"},
            ],
        )

    def __hash__(self) -> int:
        """
        Compute the hash of the multi-signals.

        Returns
        -------
        :class:`int`
            Object hash.
        """

        return hash(
            (
                int_digest(self.domain.tobytes()),
                *[hash(signal) for signal in self._signals.values()],
                self.interpolator.__name__,
                repr(self.interpolator_kwargs),
                self.extrapolator.__name__,
                repr(self.extrapolator_kwargs),
            )
        )

    def __getitem__(self, x: ArrayLike | slice) -> NDArrayFloat:
        """
        Return the corresponding range variable :math:`y` for the specified
        independent domain variable :math:`x`.

        Parameters
        ----------
        x
            Independent domain variable :math:`x`.

        Returns
        -------
        :class:`numpy.ndarray`
            Variable :math:`y` range value.

        Examples
        --------
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> multi_signals = MultiSignals(range_)
        >>> print(multi_signals)
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> multi_signals[0]
        array([ 10.,  20.,  30.])
        >>> multi_signals[np.array([0, 1, 2])]
        array([[ 10.,  20.,  30.],
               [ 20.,  30.,  40.],
               [ 30.,  40.,  50.]])
        >>> multi_signals[np.linspace(0, 5, 5)]  # doctest: +ELLIPSIS
        array([[ 10.       ...,  20.       ...,  30.       ...],
               [ 22.8348902...,  32.8046056...,  42.774321 ...],
               [ 34.8004492...,  44.7434347...,  54.6864201...],
               [ 47.5535392...,  57.5232546...,  67.4929700...],
               [ 60.       ...,  70.       ...,  80.       ...]])
        >>> multi_signals[0:3]
        array([[ 10.,  20.,  30.],
               [ 20.,  30.,  40.],
               [ 30.,  40.,  50.]])
        >>> multi_signals[:, 0:2]
        array([[  10.,   20.],
               [  20.,   30.],
               [  30.,   40.],
               [  40.,   50.],
               [  50.,   60.],
               [  60.,   70.],
               [  70.,   80.],
               [  80.,   90.],
               [  90.,  100.],
               [ 100.,  110.]])
        """

        x_r, x_c = (x[0], x[1]) if isinstance(x, tuple) else (x, slice(None))

        values = tstack([signal[x_r] for signal in self._signals.values()])

        return values[..., x_c]  # pyright: ignore

    def __setitem__(self, x: ArrayLike | slice, y: ArrayLike) -> None:
        """
        Set the corresponding range variable :math:`y` for the specified
        independent domain variable :math:`x`.

        Parameters
        ----------
        x
            Independent domain variable :math:`x`.
        y
            Corresponding range variable :math:`y`.

        Examples
        --------
        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> multi_signals = MultiSignals(range_)
        >>> print(multi_signals)
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> multi_signals[0] = 20
        >>> multi_signals[0]
        array([ 20.,  20.,  20.])
        >>> multi_signals[np.array([0, 1, 2])] = 30
        >>> multi_signals[np.array([0, 1, 2])]
        array([[ 30.,  30.,  30.],
               [ 30.,  30.,  30.],
               [ 30.,  30.,  30.]])
        >>> multi_signals[np.linspace(0, 5, 5)] = 50
        >>> print(multi_signals)
        [[   0.     50.     50.     50.  ]
         [   1.     30.     30.     30.  ]
         [   1.25   50.     50.     50.  ]
         [   2.     30.     30.     30.  ]
         [   2.5    50.     50.     50.  ]
         [   3.     40.     50.     60.  ]
         [   3.75   50.     50.     50.  ]
         [   4.     50.     60.     70.  ]
         [   5.     50.     50.     50.  ]
         [   6.     70.     80.     90.  ]
         [   7.     80.     90.    100.  ]
         [   8.     90.    100.    110.  ]
         [   9.    100.    110.    120.  ]]
        >>> multi_signals[np.array([0, 1, 2])] = np.array([10, 20, 30])
        >>> print(multi_signals)
        [[   0.     10.     20.     30.  ]
         [   1.     10.     20.     30.  ]
         [   1.25   50.     50.     50.  ]
         [   2.     10.     20.     30.  ]
         [   2.5    50.     50.     50.  ]
         [   3.     40.     50.     60.  ]
         [   3.75   50.     50.     50.  ]
         [   4.     50.     60.     70.  ]
         [   5.     50.     50.     50.  ]
         [   6.     70.     80.     90.  ]
         [   7.     80.     90.    100.  ]
         [   8.     90.    100.    110.  ]
         [   9.    100.    110.    120.  ]]
        >>> y = np.reshape(np.arange(1, 10, 1), (3, 3))
        >>> multi_signals[np.array([0, 1, 2])] = y
        >>> print(multi_signals)
        [[   0.      1.      2.      3.  ]
         [   1.      4.      5.      6.  ]
         [   1.25   50.     50.     50.  ]
         [   2.      7.      8.      9.  ]
         [   2.5    50.     50.     50.  ]
         [   3.     40.     50.     60.  ]
         [   3.75   50.     50.     50.  ]
         [   4.     50.     60.     70.  ]
         [   5.     50.     50.     50.  ]
         [   6.     70.     80.     90.  ]
         [   7.     80.     90.    100.  ]
         [   8.     90.    100.    110.  ]
         [   9.    100.    110.    120.  ]]
        >>> multi_signals[0:3] = 40
        >>> multi_signals[0:3]
        array([[ 40.,  40.,  40.],
               [ 40.,  40.,  40.],
               [ 40.,  40.,  40.]])
        >>> multi_signals[:, 0:2] = 50
        >>> print(multi_signals)
        [[   0.     50.     50.     40.  ]
         [   1.     50.     50.     40.  ]
         [   1.25   50.     50.     40.  ]
         [   2.     50.     50.      9.  ]
         [   2.5    50.     50.     50.  ]
         [   3.     50.     50.     60.  ]
         [   3.75   50.     50.     50.  ]
         [   4.     50.     50.     70.  ]
         [   5.     50.     50.     50.  ]
         [   6.     50.     50.     90.  ]
         [   7.     50.     50.    100.  ]
         [   8.     50.     50.    110.  ]
         [   9.     50.     50.    120.  ]]
        """

        y = as_float_array(y)

        x_r, x_c = (x[0], x[1]) if isinstance(x, tuple) else (x, slice(None))

        attest(
            y.ndim in range(3),
            'Corresponding "y" variable must be a numeric or a 1-dimensional '
            "or 2-dimensional array!",
        )

        if y.ndim == 0:
            y = np.tile(y, len(self._signals))
        elif y.ndim == 1:
            y = y[None, :]

        attest(
            y.shape[-1] == len(self._signals),
            'Corresponding "y" variable columns must have same count than '
            'underlying "Signal" components!',
        )

        values = list(zip(self._signals.values(), tsplit(y), strict=True))

        for signal, y in values[x_c]:  # pyright: ignore
            signal[x_r] = y

    def __contains__(self, x: ArrayLike | slice) -> bool:
        """
        Determine whether the multi-signals contains the
        specified independent domain variable :math:`x`.

        Parameters
        ----------
        x
            Independent domain variable :math:`x`.

        Returns
        -------
        :class:`bool`
            Whether :math:`x` domain value is contained.

        Examples
        --------
        >>> range_ = np.linspace(10, 100, 10)
        >>> multi_signals = MultiSignals(range_)
        >>> 0 in multi_signals
        True
        >>> 0.5 in multi_signals
        True
        >>> 1000 in multi_signals
        False
        """

        return x in first_item(self._signals.values())

    def __eq__(self, other: object) -> bool:
        """
        Determine whether the multi-signals equals the specified
        object.

        Parameters
        ----------
        other
            Object to determine for equality with the multi-signals.

        Returns
        -------
        :class:`bool`
            Whether the specified object is equal to the multi-signals.

        Examples
        --------
        >>> range_ = np.linspace(10, 100, 10)
        >>> multi_signals_1 = MultiSignals(range_)
        >>> multi_signals_2 = MultiSignals(range_)
        >>> multi_signals_1 == multi_signals_2
        True
        >>> multi_signals_2[0] = 20
        >>> multi_signals_1 == multi_signals_2
        False
        >>> multi_signals_2[0] = 10
        >>> multi_signals_1 == multi_signals_2
        True
        >>> from colour.algebra import CubicSplineInterpolator
        >>> multi_signals_2.interpolator = CubicSplineInterpolator
        >>> multi_signals_1 == multi_signals_2
        False
        """

        # NOTE: Comparing "interpolator_kwargs" and "extrapolator_kwargs" using
        # their string representation because of presence of NaNs.
        if isinstance(other, MultiSignals):
            return all(
                [
                    np.array_equal(self.domain, other.domain),
                    np.array_equal(self.range, other.range),
                    self.interpolator is other.interpolator,
                    str(self.interpolator_kwargs) == str(other.interpolator_kwargs),
                    self.extrapolator is other.extrapolator,
                    str(self.extrapolator_kwargs) == str(other.extrapolator_kwargs),
                    self.labels == other.labels,
                ]
            )

        return False

    def __ne__(self, other: object) -> bool:
        """
        Determine whether the multi-signals is not equal to the
        specified object.

        Parameters
        ----------
        other
            Object to test whether it is not equal to the multi-signals.

        Returns
        -------
        :class:`bool`
            Whether the specified object is not equal to the multi-signals.

        Examples
        --------
        >>> range_ = np.linspace(10, 100, 10)
        >>> multi_signals_1 = MultiSignals(range_)
        >>> multi_signals_2 = MultiSignals(range_)
        >>> multi_signals_1 != multi_signals_2
        False
        >>> multi_signals_2[0] = 20
        >>> multi_signals_1 != multi_signals_2
        True
        >>> multi_signals_2[0] = 10
        >>> multi_signals_1 != multi_signals_2
        False
        >>> from colour.algebra import CubicSplineInterpolator
        >>> multi_signals_2.interpolator = CubicSplineInterpolator
        >>> multi_signals_1 != multi_signals_2
        True
        """

        return not (self == other)

    def arithmetical_operation(
        self,
        a: ArrayLike | AbstractContinuousFunction,
        operation: Literal["+", "-", "*", "/", "**"],
        in_place: bool = False,
    ) -> MultiSignals:
        """
        Perform the specified arithmetical operation with operand :math:`a`,
        either on a copy or in-place.

        Parameters
        ----------
        a
            Operand :math:`a`. Can be a numeric value, array-like object, or
            another continuous function instance.
        operation
            Operation to perform.
        in_place
            Operation happens in place.

        Returns
        -------
        :class:`colour.continuous.MultiSignals`
            Multi-signals.

        Examples
        --------
        Adding a single *numeric* variable:

        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> multi_signals_1 = MultiSignals(range_)
        >>> print(multi_signals_1)
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> print(multi_signals_1.arithmetical_operation(10, "+", True))
        [[   0.   20.   30.   40.]
         [   1.   30.   40.   50.]
         [   2.   40.   50.   60.]
         [   3.   50.   60.   70.]
         [   4.   60.   70.   80.]
         [   5.   70.   80.   90.]
         [   6.   80.   90.  100.]
         [   7.   90.  100.  110.]
         [   8.  100.  110.  120.]
         [   9.  110.  120.  130.]]

        Adding an `ArrayLike` variable:

        >>> a = np.linspace(10, 100, 10)
        >>> print(multi_signals_1.arithmetical_operation(a, "+", True))
        [[   0.   30.   40.   50.]
         [   1.   50.   60.   70.]
         [   2.   70.   80.   90.]
         [   3.   90.  100.  110.]
         [   4.  110.  120.  130.]
         [   5.  130.  140.  150.]
         [   6.  150.  160.  170.]
         [   7.  170.  180.  190.]
         [   8.  190.  200.  210.]
         [   9.  210.  220.  230.]]

        >>> a = np.array([[10, 20, 30]])
        >>> print(multi_signals_1.arithmetical_operation(a, "+", True))
        [[   0.   40.   60.   80.]
         [   1.   60.   80.  100.]
         [   2.   80.  100.  120.]
         [   3.  100.  120.  140.]
         [   4.  120.  140.  160.]
         [   5.  140.  160.  180.]
         [   6.  160.  180.  200.]
         [   7.  180.  200.  220.]
         [   8.  200.  220.  240.]
         [   9.  220.  240.  260.]]

        >>> a = np.reshape(np.arange(0, 30, 1), (10, 3))
        >>> print(multi_signals_1.arithmetical_operation(a, "+", True))
        [[   0.   40.   61.   82.]
         [   1.   63.   84.  105.]
         [   2.   86.  107.  128.]
         [   3.  109.  130.  151.]
         [   4.  132.  153.  174.]
         [   5.  155.  176.  197.]
         [   6.  178.  199.  220.]
         [   7.  201.  222.  243.]
         [   8.  224.  245.  266.]
         [   9.  247.  268.  289.]]

        Adding a :class:`colour.continuous.Signal` sub-class:

        >>> multi_signals_2 = MultiSignals(range_)
        >>> print(multi_signals_1.arithmetical_operation(multi_signals_2, "+", True))
        [[   0.   50.   81.  112.]
         [   1.   83.  114.  145.]
         [   2.  116.  147.  178.]
         [   3.  149.  180.  211.]
         [   4.  182.  213.  244.]
         [   5.  215.  246.  277.]
         [   6.  248.  279.  310.]
         [   7.  281.  312.  343.]
         [   8.  314.  345.  376.]
         [   9.  347.  378.  409.]]
        """

        multi_signals = self if in_place else self.copy()

        if isinstance(a, MultiSignals):
            attest(
                len(self.signals) == len(a.signals),
                '"MultiSignals" operands must have same count than '
                'underlying "Signal" components!',
            )

            for signal_a, signal_b in zip(
                multi_signals.signals.values(), a.signals.values(), strict=True
            ):
                signal_a.arithmetical_operation(signal_b, operation, True)
        else:
            a = as_float_array(cast("ArrayLike", a))

            attest(
                a.ndim in range(3),
                'Operand "a" variable must be a numeric or a 1-dimensional or '
                "2-dimensional array!",
            )

            if a.ndim in (0, 1):
                for signal in multi_signals.signals.values():
                    signal.arithmetical_operation(a, operation, True)
            else:
                attest(
                    a.shape[-1] == len(multi_signals.signals),
                    'Operand "a" variable columns must have same count than '
                    'underlying "Signal" components!',
                )

                for signal, y in zip(
                    multi_signals.signals.values(), tsplit(a), strict=True
                ):
                    signal.arithmetical_operation(y, operation, True)

        return multi_signals

    @staticmethod
    def multi_signals_unpack_data(
        data: (
            ArrayLike
            | DataFrame
            | dict
            | MultiSignals
            | Sequence
            | Series
            | Signal
            | ValuesView
            | None
        ) = None,
        domain: ArrayLike | KeysView | None = None,
        labels: Sequence | None = None,
        dtype: Type[DTypeFloat] | None = None,
        signal_type: Type[Signal] = Signal,
        **kwargs: Any,
    ) -> Dict[str, Signal]:
        """
        Unpack specified data for multi-signals instantiation.

        Parameters
        ----------
        data
            Data to unpack for multi-signals instantiation.
        domain
            Values to initialise the multiple :class:`colour.continuous.Signal`
            sub-class instances :attr:`colour.continuous.Signal.domain`
            attribute with. If both ``data`` and ``domain`` arguments are
            defined, the latter will be used to initialise the
            :attr:`colour.continuous.Signal.domain` property.
        labels
            Names to use for the :class:`colour.continuous.Signal` sub-class
            instances.
        dtype
            Floating point data type.
        signal_type
            A :class:`colour.continuous.Signal` sub-class type.

        Other Parameters
        ----------------
        extrapolator
            Extrapolator class type to use as extrapolating function for the
            :class:`colour.continuous.Signal` sub-class instances.
        extrapolator_kwargs
            Arguments to use when instantiating the extrapolating function
            of the :class:`colour.continuous.Signal` sub-class instances.
        interpolator
            Interpolator class type to use as interpolating function for the
            :class:`colour.continuous.Signal` sub-class instances.
        interpolator_kwargs
            Arguments to use when instantiating the interpolating function
            of the :class:`colour.continuous.Signal` sub-class instances.
        name
            Multi-signals name.

        Returns
        -------
        :class:`dict`
            Mapping of labeled :class:`colour.continuous.Signal` sub-class
            instances.

        Examples
        --------
        Unpacking using implicit *domain* and data for a single signal:

        >>> range_ = np.linspace(10, 100, 10)
        >>> signals = MultiSignals.multi_signals_unpack_data(range_)
        >>> list(signals.keys())
        ['0']
        >>> print(signals["0"])
        [[   0.   10.]
         [   1.   20.]
         [   2.   30.]
         [   3.   40.]
         [   4.   50.]
         [   5.   60.]
         [   6.   70.]
         [   7.   80.]
         [   8.   90.]
         [   9.  100.]]

        Unpacking using explicit *domain* and data for a single signal:

        >>> domain = np.arange(100, 1100, 100)
        >>> signals = MultiSignals.multi_signals_unpack_data(range_, domain)
        >>> list(signals.keys())
        ['0']
        >>> print(signals["0"])
        [[  100.    10.]
         [  200.    20.]
         [  300.    30.]
         [  400.    40.]
         [  500.    50.]
         [  600.    60.]
         [  700.    70.]
         [  800.    80.]
         [  900.    90.]
         [ 1000.   100.]]

        Unpacking using data for multiple signals:

        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> signals = MultiSignals.multi_signals_unpack_data(range_, domain)
        >>> list(signals.keys())
        ['0', '1', '2']
        >>> print(signals["2"])
        [[  100.    30.]
         [  200.    40.]
         [  300.    50.]
         [  400.    60.]
         [  500.    70.]
         [  600.    80.]
         [  700.    90.]
         [  800.   100.]
         [  900.   110.]
         [ 1000.   120.]]

        Unpacking using a *dict*:

        >>> signals = MultiSignals.multi_signals_unpack_data(dict(zip(domain, range_)))
        >>> list(signals.keys())
        ['0', '1', '2']
        >>> print(signals["2"])
        [[  100.    30.]
         [  200.    40.]
         [  300.    50.]
         [  400.    60.]
         [  500.    70.]
         [  600.    80.]
         [  700.    90.]
         [  800.   100.]
         [  900.   110.]
         [ 1000.   120.]]

        Unpacking using a sequence of *Signal* instances, note how the keys
        are :class:`str` instances because the *Signal* names are used:

        >>> signals = MultiSignals.multi_signals_unpack_data(
        ...     dict(zip(domain, range_))
        ... ).values()
        >>> signals = MultiSignals.multi_signals_unpack_data(signals)
        >>> list(signals.keys())
        ['0', '1', '2']
        >>> print(signals["2"])
        [[  100.    30.]
         [  200.    40.]
         [  300.    50.]
         [  400.    60.]
         [  500.    70.]
         [  600.    80.]
         [  700.    90.]
         [  800.   100.]
         [  900.   110.]
         [ 1000.   120.]]

        Unpacking using *MultiSignals.multi_signals_unpack_data* method output:

        >>> signals = MultiSignals.multi_signals_unpack_data(dict(zip(domain, range_)))
        >>> signals = MultiSignals.multi_signals_unpack_data(signals)
        >>> list(signals.keys())
        ['0', '1', '2']
        >>> print(signals["2"])
        [[  100.    30.]
         [  200.    40.]
         [  300.    50.]
         [  400.    60.]
         [  500.    70.]
         [  600.    80.]
         [  700.    90.]
         [  800.   100.]
         [  900.   110.]
         [ 1000.   120.]]

        Unpacking using a *Pandas* `Series`:

        >>> if is_pandas_installed():
        ...     from pandas import Series
        ...
        ...     signals = MultiSignals.multi_signals_unpack_data(
        ...         Series(dict(zip(domain, np.linspace(10, 100, 10))))
        ...     )
        ...     print(signals[0])  # doctest: +SKIP
        [[  100.    10.]
         [  200.    20.]
         [  300.    30.]
         [  400.    40.]
         [  500.    50.]
         [  600.    60.]
         [  700.    70.]
         [  800.    80.]
         [  900.    90.]
         [ 1000.   100.]]

        Unpacking using a *Pandas* :class:`pandas.DataFrame`:

        >>> if is_pandas_installed():
        ...     from pandas import DataFrame
        ...
        ...     data = dict(zip(["a", "b", "c"], tsplit(range_)))
        ...     signals = MultiSignals.multi_signals_unpack_data(
        ...         DataFrame(data, domain)
        ...     )
        ...     print(signals["c"])  # doctest: +SKIP
        [[  100.    30.]
         [  200.    40.]
         [  300.    50.]
         [  400.    60.]
         [  500.    70.]
         [  600.    80.]
         [  700.    90.]
         [  800.   100.]
         [  900.   110.]
         [ 1000.   120.]]
        """

        dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

        settings = {}
        settings.update(kwargs)
        settings.update({"dtype": dtype})

        # domain_unpacked, range_unpacked, signals = (
        #   np.array([]), np.array([]), {})

        signals = {}

        if isinstance(data, Signal):
            signals[data.name] = data
        elif isinstance(data, MultiSignals):
            signals = data.signals
        elif issubclass(type(data), Sequence) or isinstance(
            data, (tuple, list, np.ndarray, Iterator, ValuesView)
        ):
            data_sequence = list(cast("Sequence", data))

            is_signal = True
            for i in data_sequence:
                if not isinstance(i, Signal):
                    is_signal = False
                    break

            if is_signal:
                for signal in data_sequence:
                    signals[signal.name] = signal_type(
                        signal.range, signal.domain, **settings
                    )
            else:
                data_array = tsplit(data_sequence)
                attest(
                    data_array.ndim in (1, 2),
                    'User "data" must be 1-dimensional or 2-dimensional!',
                )

                if data_array.ndim == 1:
                    data_array = data_array[None, :]

                for i, range_unpacked in enumerate(data_array):
                    signals[str(i)] = signal_type(range_unpacked, domain, **settings)
        elif issubclass(type(data), Mapping) or isinstance(data, dict):
            data_mapping = dict(cast("Mapping", data))

            is_signal = all(isinstance(i, Signal) for i in data_mapping.values())

            if is_signal:
                for label, signal in data_mapping.items():
                    signals[label] = signal_type(
                        signal.range, signal.domain, **settings
                    )
            else:
                domain_unpacked, range_unpacked = zip(
                    *sorted(data_mapping.items()), strict=True
                )
                for i, values_unpacked in enumerate(tsplit(range_unpacked)):
                    signals[str(i)] = signal_type(
                        values_unpacked, domain_unpacked, **settings
                    )
        elif is_pandas_installed():
            if isinstance(data, Series):
                signals["0"] = signal_type(data, **settings)
            elif isinstance(data, DataFrame):
                domain_unpacked = as_float_array(data.index.values, dtype)  # pyright: ignore
                signals = {
                    label: signal_type(
                        data[label],
                        domain_unpacked,
                        **settings,
                    )
                    for label in data
                }

        if domain is not None:
            if isinstance(domain, KeysView):
                domain = list(domain)

            domain_array = as_float_array(domain, dtype)

            for signal in signals.values():
                attest(
                    len(domain_array) == len(signal.domain),
                    'User "domain" length is not compatible with unpacked "signals"!',
                )

                signal.domain = domain_array

        signals = {str(label): signal for label, signal in signals.items()}

        if labels is not None:
            attest(
                len(labels) == len(signals),
                'User "labels" length is not compatible with unpacked "signals"!',
            )

            if len(labels) != len(set(labels)):
                labels = [f"{label} - {i}" for i, label in enumerate(labels)]

            signals = {
                str(labels[i]): signal for i, signal in enumerate(signals.values())
            }

        for label in signals:
            signals[label].name = label

        if not signals:
            signals = {"Undefined": Signal(name="Undefined")}

        return signals

    def fill_nan(
        self,
        method: Literal["Constant", "Interpolation"] | str = "Interpolation",
        default: Real = 0,
    ) -> MultiSignals:
        """
        Fill NaNs in independent domain variable :math:`x` and corresponding
        range variable :math:`y` using the specified method.

        Parameters
        ----------
        method
            *Interpolation* method linearly interpolates through the NaNs,
            *Constant* method replaces NaNs with ``default``.
        default
            Value to use with the *Constant* method.

        Returns
        -------
        :class:`colour.continuous.MultiSignals`
            Multi-signals with NaN values filled.

        Examples
        --------
        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> multi_signals = MultiSignals(range_)
        >>> multi_signals[3:7] = np.nan
        >>> print(multi_signals)
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   nan   nan   nan]
         [   4.   nan   nan   nan]
         [   5.   nan   nan   nan]
         [   6.   nan   nan   nan]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> print(multi_signals.fill_nan())
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> multi_signals[3:7] = np.nan
        >>> print(multi_signals.fill_nan(method="Constant"))
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.    0.    0.    0.]
         [   4.    0.    0.    0.]
         [   5.    0.    0.    0.]
         [   6.    0.    0.    0.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        """

        method = validate_method(method, ("Interpolation", "Constant"))

        for signal in self._signals.values():
            signal.fill_nan(method, default)

        return self

    @required("Pandas")
    def to_dataframe(self) -> DataFrame:
        """
        Convert the continuous signal to a *Pandas* :class:`pandas.DataFrame`
        class instance.

        Returns
        -------
        :class:`pandas.DataFrame`
            Continuous signal as a *Pandas* :class:`pandas.DataFrame` class
            instance.

        Examples
        --------
        >>> if is_pandas_installed():
        ...     domain = np.arange(0, 10, 1)
        ...     range_ = tstack([np.linspace(10, 100, 10)] * 3)
        ...     range_ += np.array([0, 10, 20])
        ...     multi_signals = MultiSignals(range_)
        ...     print(multi_signals.to_dataframe())  # doctest: +SKIP
                 0      1      2
        0.0   10.0   20.0   30.0
        1.0   20.0   30.0   40.0
        2.0   30.0   40.0   50.0
        3.0   40.0   50.0   60.0
        4.0   50.0   60.0   70.0
        5.0   60.0   70.0   80.0
        6.0   70.0   80.0   90.0
        7.0   80.0   90.0  100.0
        8.0   90.0  100.0  110.0
        9.0  100.0  110.0  120.0
        """

        return DataFrame(
            data=self.range,
            index=self.domain,
            columns=self.labels,  # pyright: ignore
        )
