"""Desdeo-problem variable related definitions.

This file includes definition of Variable class (with initial and current
values and value bounds), variable_builder, and error classes for Variable
and VariableBuilder.
"""

from os import path
from typing import List, Tuple, Union

import numpy as np


class VariableError(Exception):
    """Raised when an error is encountered during the handling of the
    Variable objects.

    """

    pass


class VariableBuilderError(Exception):
    """Raised when an error is encountered during the handling of the
    Variable objects.

    """

    pass


class Variable:
    """Simple variable with a name, initial value and bounds.

    Arguments:
        name (str): Name of the variable
        initial_value (float): The initial value of the variable.
        lower_bound (float, optional): Lower bound of the variable. Defaults
            to negative infinity.
        upper_bound (float, optional): Upper bound of the variable. Defaults
            to positive infinity.

    Attributes:
        name (str): Name of the variable.
        initial_value (float): Initial value of the variable.
        lower_bound (float): Lower bound of the variable.
        upper_bound (float): Upper bound of the variable.
        current_value (float): The current value the variable holds.

    Raises:
        VariableError: Bounds are incorrect.

    """

    def __init__(
        self, name: str, initial_value: float, lower_bound: float = -np.inf, upper_bound: float = np.inf
    ) -> None:

        self.__name: str = name
        self.__initial_value: float
        self.__lower_bound: float
        self.__upper_bound: float
        self.__current_value: float  # NOTE: This is probably a useless attr
        # Check that the bounds make sense
        if not (lower_bound < upper_bound):
            msg = ("Lower bound {} should be less than the upper bound " "{}.").format(lower_bound, upper_bound)
            raise VariableError(msg)

        # Check that the initial value is between the bounds
        if not (lower_bound <= initial_value <= upper_bound):
            msg = "The initial value {} should be between the " "upper ({}) and lower ({}) bounds.".format(
                initial_value, lower_bound, upper_bound
            )
            raise VariableError(msg)

        self.__lower_bound = lower_bound
        self.__upper_bound = upper_bound
        self.__initial_value = initial_value
        self.__current_value = initial_value

    @property
    def name(self) -> str:
        """Property: name

        Returns:
            str: The name of the variable.
        """
        return self.__name

    @property
    def initial_value(self) -> float:
        """Property: initial_value

        Returns:
            float: The initial value of the variable.
        """
        return self.__initial_value

    @property
    def current_value(self) -> float:
        """Property: current_value

        Returns:
            float: The current value of the variable
        """
        return self.__current_value

    @current_value.setter
    def current_value(self, value: float):
        """Setter: current_value

        Args:
            float: The updated value for the current_value variable.

        """
        self.__current_value = value

    def get_bounds(self) -> Tuple[float, float]:
        """Return the bounds of the variables as a tuple.

        Returns:
            tuple(float, float): A tuple consisting of (lower_bound,
                upper_bound)

        """
        return (self.__lower_bound, self.__upper_bound)


def variable_builder(
    names: List[str],
    initial_values: Union[List[float], np.ndarray],
    lower_bounds: Union[List[float], np.ndarray] = None,
    upper_bounds: Union[List[float], np.ndarray] = None,
) -> List[Variable]:
    """Automatically build all variable objects.

    Arguments:
        names (List[str]): Names of the variables
        initial_values (np.ndarray): Initial values taken by the variables.
        lower_bounds (Union[List[float], np.ndarray], optional): Lower bounds of the
            variables. If None, it defaults to negative infinity. Defaults to None.
        upper_bounds (Union[List[float], np.ndarray], optional): Upper bounds of the
            variables. If None, it defaults to positive infinity. Defaults to None.

    Raises:
        VariableError: Lengths of the input arrays are different.

    Returns:
        List[Variable]: List of variable objects
    """
    # assert that all inputs have the same size
    num_of_variables = len(names)
    if initial_values is None:
        initial_values = [None] * num_of_variables
    if not (num_of_variables == len(initial_values)):
        msg = (
            "The length of the list of names and the number of elements in the "
            "initial_values array should be the same"
        )
        raise VariableBuilderError(msg)
    if lower_bounds is None:
        lower_bounds = [-np.inf] * num_of_variables
    if not (num_of_variables == len(lower_bounds)):
        msg = (
            "The length of the list of names and the number of elements in the " "lower_bounds array should be the same"
        )
        raise VariableBuilderError(msg)
    if upper_bounds is None:
        upper_bounds = [np.inf] * num_of_variables
    if not (num_of_variables == len(upper_bounds)):
        msg = (
            "The length of the list of names and the number of elements in the " "upper_bounds array should be the same"
        )
        raise VariableBuilderError(msg)
    # if most checks passed
    variables = [Variable(*var_data) for var_data in zip(names, initial_values, lower_bounds, upper_bounds)]
    return variables
