"""
Interpolation methods.

Originally, the base code for `interpolate`, `mix` and `steps` was ported from the
https://colorjs.io project. Since that time, there has been significant modifications
that add additional features etc. The base logic though is attributed to the original
authors.

In general, the logic mimics in many ways the `color-mix` function as outlined in the Level 5
color draft (Oct 2020), but the initial approach was modeled directly off of the work done in
color.js.
---
Original Authors: Lea Verou, Chris Lilley
License: MIT (As noted in https://github.com/LeaVerou/color.js/blob/master/package.json)
"""
from __future__ import annotations
import math
import bisect
import functools
import itertools as it
from abc import ABCMeta, abstractmethod
from .. import util
from .. import algebra as alg
from .. spaces import HSVish, HSLish, RGBish, LChish, Labish, HWBish
from ..types import Matrix, Vector, ColorInput, Plugin, AnyColor
from typing import Callable, Sequence, Mapping, Any, Generic, Iterable, TYPE_CHECKING

if TYPE_CHECKING:  #pragma: no cover
    from ..color import Color

__all__ = ('stop', 'hint', 'interpolator', 'Interpolate', 'Interpolator')


class Sentinel(float):
    """Sentinel object that is specific to averaging that we shouldn't see defined anywhere else."""


class stop:
    """Color stop."""

    __slots__ = ('color', 'stop')

    def __init__(self, color: ColorInput, value: float) -> None:
        """Color stops."""

        self.color = color
        self.stop = value


def normalize_hue(
    h1: float,
    h2: float,
    hue: str,
    max_hue: float
) -> tuple[float, float]:
    """
    Adjust hues.

    Undefined hues are not resolved at this point in time.
    When interpolating between achromatic colors, hue specifications
    such as shorter and longer will have no affect as undefined hues
    will remain undefined meaning there is no arc length to choose
    between. This gives more intuitive interpolation results.
    """

    is_nan1 = math.isnan(h1)
    is_nan2 = math.isnan(h2)

    if is_nan1 and is_nan2:
        return h1, h2
    elif is_nan1:
        h1 = h2
    elif is_nan2:
        h2 = h1

    if hue == "specified":
        return h1, h2

    half = max_hue / 2

    h1 %= max_hue
    h2 %= max_hue

    if hue == "shorter":
        if h2 - h1 > half:
            h1 += max_hue
        elif h2 - h1 < -half:
            h2 += max_hue

    elif hue == "longer":
        if 0 < (h2 - h1) < half:
            h1 += max_hue
        elif -half < (h2 - h1) <= 0:
            h2 += max_hue

    elif hue == "increasing":
        if h2 < h1:
            h2 += max_hue

    elif hue == "decreasing":
        if h1 < h2:
            h1 += max_hue

    else:
        raise ValueError(f"Unknown hue adjuster '{hue}'")

    return h1, h2


def multi_mix(
    color_cls: type[AnyColor],
    colors: Iterable[ColorInput],
    weights: Iterable[float] | None,
    space: str,
    premultiplied: bool = True,
    carryforward: bool = False,
    powerless: bool = False,
    hue: str = 'shorter',
    average: bool = False
) -> AnyColor:
    """Mix multiple colors."""

    # Always use "powerless" when averaging
    if average:
        powerless = True

    # Get channel information
    cs = color_cls.CS_MAP[space]
    if cs.is_polar():
        hue_index = cs.hue_index()  # type: ignore[attr-defined]
        hue_max = cs.channels[hue_index].high
        is_hwb = isinstance(cs, HWBish)
        to_rad = math.tau / hue_max
        to_hue = hue_max / math.tau
    else:
        hue_index = -1
        hue_max = 0.0
        is_hwb = False
        to_rad = 0.0
        to_hue = 0.0

    obj = color_cls(space, [])
    channels = cs.channels
    chan_count = len(channels)
    avgs = [0.0] * chan_count
    counts = [0] * chan_count
    sin = 0.0
    cos = 0.0
    if hue_index != -1:
        avgs[hue_index] = math.nan
    wavg = 0.0
    no_weights = not weights
    if no_weights:
        weights = ()
    mx = 0.0

    # Sum channel values using a rolling average. Apply premultiplication and additional weighting as required.
    count = 0
    sentinel = Sentinel()
    fill = 1 if no_weights else sentinel
    for c, w in it.zip_longest(colors, [] if no_weights else weights, fillvalue=fill):  # type: ignore[arg-type]

        # Handle explicit weighted cases
        if not no_weights:
            # If there are more weights than colors, ignore additional weights
            if c is sentinel:
                break

            # If there are less weights than colors, assume full weight for colors without weights
            if w is sentinel:
                w = mx

            # Negative weights are considered as zero weight
            if w < 0.0:
                w = 0.0

            # Track the largest weight so we can populate colors with no weights
            elif w > mx:
                mx = w

        if carryforward:
            obj.mutate(c)  # type: ignore[arg-type]
            carryforward_convert(obj, space, hue_index, powerless)
        else:
            obj.update(c)  # type: ignore[arg-type]
            if powerless and hue_index >= 0 and not math.isnan(obj[hue_index]) and obj.is_achromatic():
                obj[hue_index] = math.nan

        coords = obj[:]

        # Average weights
        count += 1
        wavg += (w - wavg) / count

        # Include alpha in average if it is defined. If not defined, skip, but assume color is opaque.
        alpha = coords[-1]
        if math.isnan(alpha):
            alpha = 1.0
        else:
            counts[-1] += 1
            avgs[-1] += ((coords[-1] * w) - avgs[-1]) / counts[-1]

        # Color channels use the provided weight and alpha weighting if premultiply is enabled
        wfactor = (alpha * w) if premultiplied else w
        for i in range(chan_count - 1):
            coord = coords[i]
            is_hue = i == hue_index
            # When averaging, mo need to include a color component alpha is zero and not doing premultiplication.
            # Also, don't include if coordinate is undefined; hues are an exception when not averaging.
            if (not math.isnan(coord) or (not average and is_hue)) and (premultiplied or not average or alpha):
                counts[i] += 1
                n = counts[i]
                if is_hue:
                    # Circular mean
                    if average:
                        rad = coord * to_rad
                        sin += ((math.sin(rad) * wfactor) - sin) / n
                        cos += ((math.cos(rad) * wfactor) - cos) / n
                    # CSS style hue interpolation
                    else:
                        avgs[i], coord = normalize_hue(avgs[i], coord, hue, hue_max)
                        avgs[i] += (coord - avgs[i]) * (w / (wavg * n)) if wavg else math.nan
                else:
                    avgs[i] += (coord * wfactor - avgs[i]) / n

    if not count:
        raise ValueError('At least one color must be provided in order to mix colors')

    # Undo premultiplication and weighting to get the final color.
    # Adjust a channel to be undefined if all values in channel were undefined or if it is an achromatic hue channel.
    if not wavg:
        wavg = math.nan
    avgs[-1] = alpha = math.nan if not counts[-1] else avgs[-1] / wavg
    if math.isnan(alpha):
        alpha = 1.0
    factor = (alpha * wavg) if premultiplied else wavg

    for i in range(chan_count - 1):
        if not counts[i] or (average and not alpha):
            avgs[i] = math.nan
        elif i == hue_index:
            # Circular mean
            if average:
                sin /= factor
                cos /= factor
                # Combine polar parts into a degree
                if abs(sin) < util.ACHROMATIC_THRESHOLD_SM and abs(cos) < util.ACHROMATIC_THRESHOLD_SM:
                    avgs[i] = math.nan
                else:
                    avg_theta = math.atan2(sin, cos) * to_hue
                    avgs[i] = (avg_theta + hue_max) if avg_theta < 0 else avg_theta
        elif factor:
            avgs[i] /= factor

    # Create the color.
    color = obj.update(space, avgs[:-1], avgs[-1])
    # When averaging, if polar and no defined hue, force an achromatic state.
    if average and cs.is_polar():
        if is_hwb and math.isnan(color[hue_index]):
            w, b = cs.indexes()[1:]
            if color[w] + color[b] < 1:
                color[w] = 1 - color[b]
        elif math.isnan(color[hue_index]) and not math.isnan(color[cs.radial_index()]):  # type: ignore[attr-defined]
            color[cs.radial_index()] = 0  # type: ignore[attr-defined]
    return color


def midpoint(t: float, h: float = 0.5) -> float:
    """Midpoint easing function."""

    return 0.0 if h <= 0 or h >= 1 else alg.spow(t, math.log(0.5) / math.log(h))


def hint(mid: float) -> Callable[..., float]:
    """A generate a midpoint easing function."""

    return functools.partial(midpoint, h=mid)


def normalize_domain(d: Vector) -> Vector:
    """Normalize domain between 0 and 1."""

    total = d[-1] - d[0]
    regions = len(d) - 1
    values = [0.0]
    for index in range(regions):
        a, b = d[index:index + 2]
        l = b - a
        values.append(values[-1] + (l / total if total else 0))
    return values


class Interpolator(Generic[AnyColor], metaclass=ABCMeta):
    """Interpolator."""

    def __init__(
        self,
        coordinates: Matrix,
        channel_names: Sequence[str],
        color_cls: type[AnyColor],
        easings: list[Callable[..., float] | None],
        stops: dict[int, float],
        space: str,
        out_space: str,
        progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None,
        premultiplied: bool,
        extrapolate: bool = False,
        domain: Sequence[float] | None = None,
        padding: float | tuple[float, float] | None = None,
        hue: str = 'shorter',
        **kwargs: Any
    ):
        """Initialize."""

        self.start = stops[0]
        self.end = stops[len(stops) - 1]
        self.stops = stops
        self.easings = easings
        self.coordinates = coordinates
        self.length = len(self.coordinates)
        self.channel_names = channel_names
        self.color_cls = color_cls
        self.progress = progress
        self.space = space
        self._out_space = out_space
        self.extrapolate = extrapolate
        self.current_easing = None  # type: Mapping[str, Callable[..., float]] | Callable[..., float] | None
        self.hue = hue
        self.increasing = True
        cs = self.color_cls.CS_MAP[space]
        if cs.is_polar():
            self.hue_index = cs.hue_index()  # type: ignore[attr-defined]
            self.max_hue = cs.channels[self.hue_index].high
            self.half_hue = self.max_hue / 2
        else:
            self.hue_index = -1
            self.max_hue = 0.0
            self.half_hue = 0.0
        self.premultiplied = premultiplied

        # Calculate padded start and end
        self._padding = None  # type: tuple[float, float] | None
        if padding is not None:
            self.padding(padding)

        # Set the domain
        self._domain = []  # type: Vector

        if domain is not None:
            self.domain(domain)

        self.setup()

    def discretize(
        self,
        steps: int = 2,
        max_steps: int = 1000,
        max_delta_e: float = 0,
        delta_e: str | None = None,
        delta_e_args: dict[str, Any] | None = None,
    ) -> Interpolator[AnyColor]:
        """Make the interpolation a discretized interpolation."""

        from .linear import Linear

        # Get the discrete steps for the new discrete interpolation
        colors = self.steps(steps, max_steps, max_delta_e, delta_e, delta_e_args)  # type: list[AnyColor]

        if not colors:
            raise ValueError('Discrete interpolation requires at least 1 discrete step.')

        # Calculate new coordinate list and discrete stops
        total = len(colors)
        coords = []
        stops = {}
        count = 0
        for r in range(1, total):
            pre = r - 1
            nxt = r
            step1 = colors[pre][:]
            step2 = colors[nxt][:]
            stp = r / total
            stops[count] = stp
            stops[count + 1] = stp
            coords.extend([step1, step2])
            count += 2

        if total == 1:
            coords.extend([colors[-1][:], colors[-1][:]])
            stops[0] = 0.0
            stops[1] = 1.0

        return Linear().interpolator(
            coordinates=coords,
            channel_names=self.channel_names,
            color_cls=self.color_cls,
            easings=[None] * (len(coords) - 1),
            stops=stops,
            space=self.space,
            out_space=self._out_space,
            progress=self.progress,
            premultiplied=self.premultiplied,
            extrapolate=self.extrapolate,
            domain=[],
            padding=None,
            hue = 'shorter'
        )

    def out_space(self, space: str) -> None:
        """Set output space."""

        if space not in self.color_cls.CS_MAP:
            raise ValueError(f"'{space}' is not a valid color space")
        self._out_space = space

    def domain(self, domain: Sequence[float]) -> None:
        """Set the domain."""

        self._domain = list(domain)
        self.increasing = not domain or len(domain) == 1 or domain[1] > domain[0]

    def padding(self, padding: float | Sequence[float]) -> None:
        """Add/adjust padding."""

        # Make sure it is a sequence
        padding = [padding] if not isinstance(padding, Sequence) else [*padding]

        # If it is empty
        if not padding:
            self._padding = None
            return

        l = len(padding)

        # Too many values
        if l > 2:
            raise ValueError("Padding must be either a single numerical value or a sequence of 2 values")

        # Apply padding to both
        if l == 1:
            padding.append(padding[0])

        # No padding is required
        if padding[0] == padding[1] == 0.0:
            self._padding = None

        # Calculate padded start and end
        else:
            self._padding = (0.0 + padding[0], 1.0 - padding[1])

    @abstractmethod
    def setup(self) -> None:
        """Setup."""

    @abstractmethod
    def interpolate(
        self,
        point: float,
        index: int,
    ) -> Vector:
        """Interpolate."""

    def steps(
        self,
        steps: int = 2,
        max_steps: int = 1000,
        max_delta_e: float = 0,
        delta_e: str | None = None,
        delta_e_args: dict[str, Any] | None = None,
    ) -> list[AnyColor]:
        """Steps."""

        actual_steps = steps

        if delta_e_args is None:
            delta_e_args = {}

        # Allocate at least two steps if we are doing a maximum delta E,
        if max_delta_e != 0 and actual_steps < 2:
            actual_steps = 2

        # Make sure we don't start out allocating too many colors
        if max_steps is not None:
            actual_steps = min(actual_steps, max_steps)

        ret = []  # type: list[tuple[float, AnyColor]]
        if actual_steps == 1:
            ret = [(0.5, self(0.5))]
        elif actual_steps > 1:
            step = 1 / (actual_steps - 1)
            for i in range(actual_steps):
                p = i * step
                ret.append((p, self(p)))

        # Iterate over all the stops inserting stops in between all colors
        # if we have any two colors with a max delta greater than what was requested.
        # We inject between every stop to ensure the midpoint does not shift.
        if max_delta_e > 0:
            # Initial check to see if we need to insert more stops
            m_delta = 0.0
            for i in range(1, len(ret)):
                m_delta = max(
                    m_delta,
                    ret[i - 1][1].delta_e(
                        ret[i][1],
                        method=delta_e,
                        **delta_e_args
                    )
                )

            # If we currently have delta over our limit inject more stops.
            # If inserting between every color would push us over the max_steps, halt.
            total = len(ret)
            while m_delta > max_delta_e and (total * 2 - 1 <= max_steps):
                # Inject stops while measuring again to see if it was sufficient
                m_delta = 0.0
                i = 1
                index = 1
                while index < total:
                    prev = ret[index - 1]
                    cur = ret[index]
                    p = (cur[0] + prev[0]) / 2
                    color = self(p)
                    m_delta = max(
                        m_delta,
                        color.delta_e(prev[1], method=delta_e, **delta_e_args),
                        color.delta_e(cur[1], method=delta_e, **delta_e_args)
                    )
                    ret.insert(index, (p, color))
                    total += 1
                    index += 2

        return [ri[1] for ri in ret]

    def premultiply(self, coords: Vector, alpha: float | None = None) -> None:
        """Apply premultiplication to semi-transparent colors."""

        if alpha is not None:
            coords[-1] = alpha
        else:
            alpha = coords[-1]

        if math.isnan(alpha) or alpha == 1.0:
            return

        for i, value in enumerate(coords[:-1]):

            # Wrap the angle
            if i == self.hue_index:
                continue

            coords[i] = value * alpha

    def postdivide(self, coords: Vector) -> None:
        """Undo premultiplication of semi-transparent colors."""

        alpha = coords[-1]

        if math.isnan(alpha) or alpha in (0.0, 1.0):
            return

        for i, value in enumerate(coords[:-1]):

            # Wrap the angle
            if i == self.hue_index:
                continue

            coords[i] = value / alpha

    def begin(self, point: float, first: float, last: float, index: int) -> AnyColor:
        """
        Begin interpolation.

        - Ensure point is relative to the stops.
        - Get the appropriate easing function.
        - Call interpolation.
        - Return a color
        """

        # Adjust stop to be relative to the given stops
        r = last - first
        if point < first:
            adjusted_time = point - first if self.extrapolate else 0
        elif point > last:
            adjusted_time = 1 + point - last if self.extrapolate else 1
        else:
            adjusted_time = (point - first) / r if r else 1

        # Do we have an easing function between these stops?
        self.current_easing = self.easings[index - 1]
        if self.current_easing is None:
            self.current_easing = self.progress

        # Interpolate color and return it
        coords = self.interpolate(adjusted_time, index)
        if self.premultiplied:
            self.postdivide(coords)

        # Create the color and ensure it is in the correct color space.
        color = self.color_cls(self.space, coords[:-1], coords[-1])
        return color.convert(self._out_space, in_place=True)

    def ease(self, t: float, channel_index: int) -> float:
        """Provide a progression time and channel index."""

        progress = None
        if self.current_easing is not None:
            # Do we have an easing function, or mapping with a channel easing function?
            name = self.channel_names[channel_index]
            if isinstance(self.current_easing, Mapping):
                progress = self.current_easing.get(name)
                if progress is None:
                    progress = self.current_easing.get('all')
            else:
                progress = self.current_easing

        return progress(t) if progress is not None else t

    def handle_domain(self, p: float) -> float:
        """
        Scale a point from a custom domain into a domain of 0 to 1.

        This allows a user to have a custom domain, but for us to adapt back to 0 and 1
        so that our logic can remain consistent.
        """

        import operator as op

        le, ge = (op.le, op.ge) if self.increasing else (op.ge, op.le)
        bisect_left = bisect.bisect_left if self.increasing else alg.reversed_bisect_left

        if le(p, self._domain[0]):
            p = (p - self._domain[0]) / (self._domain[-1] - self._domain[0]) if self.extrapolate else 0.0
        elif ge(p, self._domain[-1]):
            p = 1.0 + (p - self._domain[-1]) / (self._domain[-1] - self._domain[0]) if self.extrapolate else 1.0
        else:
            regions = len(self._domain) - 1
            size = (1 / regions)
            index = bisect_left(self._domain, p) - 1
            a, b = self._domain[index:index + 2]
            l = b - a
            adjusted = ((p - a) / l) if l else 0.0
            p = size * index + (adjusted * size)
        return p

    def __call__(self, point: float) -> AnyColor:
        """Find which leg of the interpolation the request is between."""

        if self._domain:
            point = self.handle_domain(point)

        if self._padding:
            slope = (self._padding[1] - self._padding[0])
            point = self._padding[0] + slope * point
            if not self.extrapolate:
                point = min(max(point, self._padding[0]), self._padding[1])

        # See if point extends past either the first or last stop
        if point < self.start:
            first, last = self.start, self.stops[1]
            return self.begin(point, first, last, 1)
        elif point > self.end:
            first, last = self.stops[self.length - 2], self.end
            return self.begin(point, first, last, self.length - 1)
        else:
            # Iterate stops to find where our point falls between
            first = self.start
            for i in range(1, self.length):
                last = self.stops[i]
                if point <= last:
                    return self.begin(point, first, last, i)
                first = last

        # We shouldn't ever hit this, but provided for typing.
        # If we do hit this, it would be a bug.
        raise RuntimeError(f'Iterpolation could not be found for {point}')  # pragma: no cover


class Interpolate(Plugin, metaclass=ABCMeta):
    """Interpolation plugin."""

    NAME = ""

    @abstractmethod
    def interpolator(
        self,
        coordinates: Matrix,
        channel_names: Sequence[str],
        color_cls: type[AnyColor],
        easings: list[Callable[..., float] | None],
        stops: dict[int, float],
        space: str,
        out_space: str,
        progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None,
        premultiplied: bool,
        extrapolate: bool = False,
        domain: Vector | None = None,
        padding: float | tuple[float, float] | None = None,
        hue: str = 'shorter',
        **kwargs: Any
    ) -> Interpolator[AnyColor]:
        """Get the interpolator object."""

    def get_space(self, space: str | None, color_cls: type[AnyColor]) -> str:
        """
        Get and validate the color space for interpolation.

        If no space is defined, return an appropriate default color space.
        """

        if space is None:
            space = color_cls.INTERPOLATE
        return space

    def weighted_mix(
        self,
        color_cls: type[AnyColor],
        colors: Iterable[ColorInput],
        weights: Iterable[float] | None,
        space: str | None,
        premultiplied: bool = True,
        carryforward: bool = False,
        powerless: bool = False,
        hue: str = 'shorter',
        **kwargs: Any
    ) -> AnyColor:
        """Mix a list of colors together with weights."""

        return multi_mix(
            color_cls,
            colors,
            weights,
            self.get_space(space, color_cls),
            premultiplied,
            carryforward,
            powerless,
            hue
        )


def calc_stops(stops: dict[int, float], count: int) -> dict[int, float]:
    """Calculate stops."""

    # Ensure the first stop is set to zero if not explicitly set
    if 0 not in stops or stops[0] is None:
        stops[0] = 0

    last = stops[0] * 100
    highest = last
    empty = None
    final = {}

    # Build up normalized stops
    for i in range(count):
        value = stops.get(i)
        if value is not None:
            value *= 100

        # Found an empty hole, track the start
        if value is None and empty is None:
            empty = i - 1
            continue
        elif value is None:
            continue

        # We can't have a stop decrease in progression
        if value < last:
            value = last

        # Track the largest explicit value set
        if value > highest:
            highest = value

        # Fill in hole if one exists.
        # Holes will be evenly space between the
        # current and last stop.
        if empty is not None:
            r = i - empty
            increment = (value - last) / r
            for j in range(empty + 1, i):
                last += increment
                final[j] = last / 100
            empty = None

        # Set the stop and track it as the last
        last = value
        final[i] = last / 100

    # If there is a hole at the end, fill in the hole,
    # equally spacing the stops from the last to 100%.
    # If the last is greater than 100%, then all will
    # be equal to the last.
    if empty is not None:
        r = (count - 1) - empty
        if highest > 100:
            increment = 0
        else:
            increment = (100 - last) / r
        for j in range(empty + 1, count):
            last += increment
            final[j] = last / 100

    return final


def process_mapping(
    progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None,
    aliases: Mapping[str, str]
) -> Mapping[str, Callable[..., float]] | Callable[..., float] | None:
    """Process a mapping, such that it is not using aliases."""

    if not isinstance(progress, Mapping):
        return progress
    return {aliases.get(k, k): v for k, v in progress.items()}


def carryforward_convert(color: Color, space: str, hue_index: int, powerless: bool) -> None:  # pragma: no cover
    """Carry forward undefined values during conversion."""

    carry = []
    needs_conversion = space != color.space()

    # Only look to "carry forward" if we have undefined channels
    if needs_conversion and any(math.isnan(c) for c in color):
        cs1 = color._space
        cs2 = color.CS_MAP[space]
        channels = {
            'R': False, 'G': False, 'B': False, 'H': False, 'C': False,
            'L': False, 'V': False, 'a': False, 'b': False
        }

        # Gather undefined channels
        if isinstance(cs1, RGBish):
            for i, name in zip(cs1.indexes(), ('R', 'G', 'B')):
                if math.isnan(color[i]):
                    channels[name] = True
        elif isinstance(cs1, LChish):
            for i, name in zip(cs1.indexes(), ('L', 'C', 'H')):
                if math.isnan(color[i]):
                    channels[name] = True
        elif isinstance(cs1, Labish):
            for i, name in zip(cs1.indexes(), ('L', 'a', 'b')):
                if math.isnan(color[i]):
                    channels[name] = True
        elif isinstance(cs1, HSLish):
            for i, name in zip(cs1.indexes(), ('H', 'C', 'L')):
                if math.isnan(color[i]):
                    channels[name] = True
        elif isinstance(cs1, HSVish):
            for i, name in zip(cs1.indexes(), ('H', 'C', 'V')):
                if math.isnan(color[i]):
                    channels[name] = True
        elif cs1.is_polar():
            if math.isnan(color[cs1.hue_index()]):  # type: ignore[attr-defined]
                channels['H'] = True

        # Carry alpha forward if undefined
        if math.isnan(color[-1]):
            carry.append(-1)

        # Channels that need to be carried forward
        if isinstance(cs2, RGBish):
            indexes = cs2.indexes()
            for e, name in enumerate(('R', 'G', 'B')):
                if channels[name]:
                    carry.append(indexes[e])
        elif isinstance(cs2, Labish):
            indexes = cs2.indexes()
            for e, name in enumerate(('L', 'a', 'b')):
                if channels[name]:
                    carry.append(indexes[e])
        elif isinstance(cs2, LChish):
            indexes = cs2.indexes()
            for e, name in enumerate(('L', 'C', 'H')):
                if channels[name]:
                    carry.append(indexes[e])
        elif isinstance(cs2, HSLish):
            indexes = cs2.indexes()
            for e, name in enumerate(('H', 'C', 'L')):
                if channels[name]:
                    carry.append(indexes[e])
        elif isinstance(cs2, HSVish):
            indexes = cs2.indexes()
            for e, name in enumerate(('H', 'C', 'V')):
                if channels[name]:
                    carry.append(indexes[e])
        elif hue_index >= 0:
            if channels['H']:
                carry.append(cs2.hue_index())  # type: ignore[attr-defined]

    # Convert the color space
    if needs_conversion:
        color.convert(space, in_place=True)

        # Carry the undefined values forward
        for i in carry:
            color[i] = math.nan

    # Normalize hue if cylindrical and achromatic
    # Carry forward is not needed as nothing was lost through conversion
    elif powerless and hue_index >= 0 and color.is_achromatic():
        color[hue_index] = math.nan


def weighted_mix(
    color_cls: type[AnyColor],
    interpolator: str,
    colors: Iterable[ColorInput],
    weights: Iterable[float] | None,
    space: str | None,
    premultiplied: bool = True,
    carryforward: bool = False,
    powerless: bool = False,
    hue: str = 'shorter',
    **kwargs: Any
) -> AnyColor:
    """Perform a weighted mix between multiple colors."""

    plugin = color_cls.INTERPOLATE_MAP.get(interpolator)
    if plugin is None:
        raise ValueError(f"'{interpolator}' is not a recognized interpolator")

    return plugin.weighted_mix(
        color_cls,
        colors,
        weights,
        space,
        premultiplied,
        carryforward,
        powerless,
        hue,
        **kwargs
    )


def interpolator(
    color_cls: type[AnyColor],
    interpolator: str,
    colors: Sequence[ColorInput | stop | Callable[..., float]],
    space: str | None,
    out_space: str | None,
    progress: Mapping[str, Callable[..., float]] | Callable[..., float] | None,
    hue: str,
    premultiplied: bool,
    extrapolate: bool,
    domain: Vector | None = None,
    padding: float | tuple[float, float] | None = None,
    carryforward: bool = False,
    powerless: bool = False,
    **kwargs: Any
) -> Interpolator[AnyColor]:
    """Get desired blend mode."""

    plugin = color_cls.INTERPOLATE_MAP.get(interpolator)
    if not plugin:
        raise ValueError(f"'{interpolator}' is not a recognized interpolator")

    # Construct piecewise interpolation object
    stops = {}  # type: Any

    space = plugin.get_space(space, color_cls)

    if not colors:
        raise ValueError('At least one color must be specified.')

    if isinstance(colors[0], stop):
        current = color_cls(colors[0].color)
        stops[0] = colors[0].stop
    elif not callable(colors[0]):
        current = color_cls(colors[0])
        stops[0] = None
    else:
        raise ValueError('Cannot have an easing function as the first item in an interpolation list')

    if out_space is None:
        out_space = space

    # Adjust to space
    cs = current.CS_MAP[space]
    is_cyl = cs.is_polar()
    hue_index = cs.hue_index() if is_cyl else -1  # type: ignore[attr-defined]
    if carryforward:
        carryforward_convert(current, space, hue_index, powerless)
    elif space != current.space():
        current.convert(space, in_place=True)
    elif powerless and is_cyl and current.is_achromatic():
        current[hue_index] = math.nan

    easing = None  # type: Any
    easings = []  # type: Any
    coords = [current[:]]

    i = 0
    for x in colors[1:]:

        # Normalize all colors as Piecewise objects
        if isinstance(x, stop):
            i += 1
            stops[i] = x.stop
            color = current.new(x.color)
        elif callable(x):
            easing = x
            continue
        else:
            i += 1
            color = current.new(x)
            stops[i] = None

        # Adjust color to space
        if carryforward:
            carryforward_convert(color, space, hue_index, powerless)
        elif space != color.space():
            color.convert(space, in_place=True)
        elif powerless and is_cyl and color.is_achromatic():
            color[hue_index] = math.nan

        # Create an entry interpolating the current color and the next color
        coords.append(color[:])
        easings.append(easing if easing is not None else progress)

        # The "next" color is now the "current" color
        easing = None
        current = color

    i += 1
    if i == 1:
        coords.append(coords[-1][:])
        easings.append(None)
        stops[i] = None
        hue = 'shorter'
        i += 1

    # Calculate stops
    stops = calc_stops(stops, i)
    kwargs['hue'] = hue

    # Send the interpolation list along with the stop map to the Piecewise interpolator
    return plugin.interpolator(
        coords,
        current._space.channels,
        color_cls,
        easings,
        stops,
        space,
        out_space,
        process_mapping(progress, current._space.CHANNEL_ALIASES),
        premultiplied,
        extrapolate,
        domain,
        padding,
        **kwargs
    )
