from typing import Callable


def interpolator(source_low: float, source_high: float,
                 dest_low: float = 0, dest_high: float = 1,
                 lock_range=False) -> Callable[[float], float]:
    """
    General interpolation function factory

    :param source_low:
        Low input value
    :param source_high:
        High input value
    :param dest_low:
        Desired output value when the input is equal to source_low
    :param dest_high:
        Desired output value when the input is equal to source_high
    :param lock_range:
        If true (default) the output values will always be strictly constrained to the dest_high, dest_low range,
        if false then input values outside of source_low, source_high will result in output values beyond this
        constraint, acting as true linear interpolation.
    :return:
        a function of source->dest which applies the specified interpolation
    """
    if source_low > source_high:
        # Ensure that source_low is always numerically less than source_high
        return interpolator(source_high, source_low, dest_high, dest_low, lock_range)
    if source_low == source_high:
        raise ValueError(f'unable to create interpolator, source_low == source_high == {source_low}')

    source_range_inverse = 1 / (source_high - source_low)

    # If we're not locked then just use interpolation
    def inner_interpolator(value: float) -> float:
        i = (value - source_low) * source_range_inverse
        return i * dest_high + (1.0 - i) * dest_low

    # Otherwise return a version which clamps the interpolation value between 0 and 1
    def inner_locked_interpolator(value: float) -> float:
        i = max(0.0, min(1.0, (value - source_low) * source_range_inverse))
        return i * dest_high + (1.0 - i) * dest_low

    # Return a function from source -> dest
    return inner_locked_interpolator if lock_range else inner_interpolator