from math import floor
from statistics import mean
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union


def weighted_mean(values: List[Union[float, int]], weights: List[Union[int, float]] = []) -> Optional[float]:
    """
    This will generate a mean value for the list of provided `values`, where each value is multiplied by the
    corresponding weight in the same position. If there are more `values` than `weights`, remaining values will receive
    weight 1.0. If there's more `weights` than `values`, it will only use the first `n` weights, where `n` is the number
    of values.
    :param values: the numeric values to generated a weighted mean over
    :param weights: the weights for each position of the values
    :return: the weighted average value of the provided values
    """
    if len(values) == 0:
        return None

    if weights is None or len(weights) == 0:
        return mean(values)

    total_weight = 0.0
    total_value = 0.0

    num_weights = len(weights)

    for idx, val in enumerate(values):
        current_weight = weights[-1] if num_weights < idx + 1 else weights[idx]
        total_weight += current_weight
        total_value += val * current_weight

    if total_weight == 0.0:
        return None

    return total_value / total_weight


def normalise(value: Union[float, int], min_val: Union[float, int], max_val: Union[float, int], limit=True,
              mid_point: Optional[Union[float, int]] = None) -> float:
    """
    Normalises the given input `value` between min_val and max_val as number between 0 and 1. If the min_val is provided
    larger than the max_val, the function will automatically invert them and provide the `1 - normalised_value`.
    :param value: the number to normalise
    :param min_val: the lower boundary representing 0.0
    :param max_val: the upper boundary representing 1.0
    :param limit: a boolean flag indicating whether a value between 0 and 1 is returned. Otherwise, values greater than
    1 and lower than 0 can be returned.
    :param mid_point: an optional mid-point to use for the normalisation. If provided, the normalisation will be
    performed between the min_val and the mid_point, and the mid_point and the max_val.
    :return: the normalised value expressed as where the value sits between min_val and max_val
    """
    if value is None or min_val is None or max_val is None:
        return 0.0  # invalid input

    min_value = min_val
    max_value = max_val
    inverted = False
    if max_val < min_val:
        inverted = True
        min_value = max_val
        max_value = min_val

    if value < min_value and limit is True:
        return 0.0 if inverted is False else 1.0

    if value > max_value and limit is True:
        return 1.0 if inverted is False else 0.0

    if max_value == min_value:
        return 0.5

    def _handle_return(norm):
        return norm if inverted is False else 1.0 - norm  # handle inverted flag

    if mid_point is None:
        return _handle_return((float(value) - float(min_value)) / (float(max_value) - float(min_value)))
    else:
        if value <= mid_point:
            # value is below mid-point (return 0 - 0.5)
            return _handle_return(((float(value) - float(min_value)) / (float(mid_point) - float(min_value))) * 0.5)
        else:
            # value is above mid-point (return 0.5 - 1.0)
            return _handle_return(
                (((float(value) - float(mid_point)) / (float(max_value) - float(mid_point))) * 0.5) + 0.5)


def discretise(value: Union[float, int], precision: Union[float, int] = 1, floor=False,
               ceil=False) -> Union[float, int]:
    """
    This function will round the given value to the nearest multiple of the given precision. There are 2 boolean flags
    available that can force the function to return the lower or upper boundary of a precision interval. If neither is
    set, the function will return the mid-point of the interval.
    :param value: the value to discretise
    :param precision: the precision to use for the discretisation
    :param floor: a boolean flag indicating whether the lower boundary of the precision interval should be returned
    :param ceil: a boolean flag indicating whether the upper boundary of the precision interval should be returned
    :return: the discretised value
    """
    if value is None:
        return None
    remainder = value % precision
    if ceil is True:
        return value - remainder + precision
    if floor is True:
        return value - remainder
    # mid point is default fallback
    return (value - remainder) + (precision * 0.5)


def compute_linear_function_parameters(xy_points: List[tuple]) -> Tuple[float, float, float]:
    """
    Computes m and b to minimise the error of all given points to map onto a linear function y = m * x + b.
    :param xy_points: a list of tuples representing the (x, y) points
    :return: a tuple with 3 elements containing the parameters m, b and the total (sum) error of all points when
    comparing against the linear function.
    """
    n = len(xy_points)

    # arrays to store all the elements
    x2 = []  # x squared
    xy = []  # x * y
    x = []  # all x-values
    y = []  # all y-values

    # collect all point data
    for (x_val, y_val) in xy_points:
        x2.append(x_val * x_val)
        xy.append(x_val * y_val)
        x.append(x_val)
        y.append(y_val)

    # determine m and b
    m = ((n * sum(xy)) - (sum(x) * sum(y))) / ((n * sum(x2)) - (sum(x) * sum(x)))
    b = (sum(y) - (m * sum(x))) / n

    # calculate total error
    error = 0.0
    for (x, y) in xy_points:
        error += abs(y - ((m * x) + b))

    return m, b, error


def group_objects(
    objects: List[Any], key: Optional[callable] = None, count: bool = False
) -> Dict[Any, Union[List[Any], int]]:
    result = {}
    for item in objects:
        k = item if key is None else key(item)
        if k not in result:
            result[k] = 0 if count is True else []

        if count is True:
            result[k] += 1
        else:
            result[k].append(item)
    return result


def sort_grouping(
    grouping: Dict[Any, Union[int, List[Any]]], 
    reverse=False, 
    count_key: str = "total", 
    count_exec: Optional[callable] = None,
) -> List[Dict[str, Union[Any, int]]]:
    result = []
    for k, v in grouping.items():
        count = v  # default case, if values are numbers
        if isinstance(v, Iterable):
            # list provided, either measure the length or call the count exec function on the list
            count = len(v) if count_exec is None else count_exec(v)
        result.append({"key": k, "value": v, count_key: count})

    # sorting
    return list(sorted(result, key=lambda e: e[count_key], reverse=reverse))


def split_into_batches(
    items: Iterable, num_batches: int, consecutive: bool = True,
) -> List[list]:
    """
    Splits the provided list of items into a number of batches. 2 modes are available and exactly one of them has to be
    set to True. If less items than num_batches are provided, this function will return empty batches. The maximum 
    difference in items per batch will be 1
    :param items: the list of items to split up
    :param num_batches: the number of batches to return, this will determine the length of the return
    :param consecutive: if set to True, each batch will have items in the same order as they are in items. If set to 
    False, we iterate through the items and always pick the next batch to put it in.
    :returns: a list of batches, each batch represented by a list of items (which can be empty)
    """
    if num_batches <= 1:
        num_batches = 1  # force at least one batch

    # init batches
    batches = {}
    for i in range(0, num_batches):
        batches[i] = []
    if len(items) == 0:
        # better not risk any issues below
        return list(batches.values())

    # consecutive
    if consecutive is True:
        batch_size = floor(len(items) / num_batches)
        extras = len(items) - batch_size * num_batches
        current_start = 0
        for i in range(0, num_batches):
            current_size = batch_size if i >= extras else batch_size + 1
            batches[i] = items[current_start:current_start + current_size]
            current_start += current_size

    # alternating
    else:
        current_idx = 0
        for item in items:
            batches[current_idx % num_batches].append(item)
            current_idx += 1

    return list(batches.values())


def split_into_size_batches(items: Iterable, batch_size: int) -> List[list]:
    if len(items) == 0:
        return []  # no batches
    if batch_size < 1:
        batch_size = 1  # force at least batch size of 1

    batches = []
    remaining = [i for i in items]
    while len(remaining) > 0:
        batches.append(remaining[0:batch_size])
        remaining = remaining[batch_size:]

    return batches
