# Copyright (C) 2018-2023 Team tiramisu (see AUTHORS for all contributors)
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""some functions to validates or calculates value
"""
from typing import Any, List, Optional
from operator import add, mul, sub, truediv
from ipaddress import ip_address, ip_interface, ip_network
from .i18n import _
from .setting import undefined
from .error import display_list


FUNCTION_WAITING_FOR_DICT = []


def function_waiting_for_dict(function):
    """functions (calculation or validation) receive by default only the value of other options
    all functions declared with this function recieve a dict with option informations
    (value, name, ...)
    """
    name = function.__name__
    if name not in FUNCTION_WAITING_FOR_DICT:
        FUNCTION_WAITING_FOR_DICT.append(name)
    return function


@function_waiting_for_dict
def valid_network_netmask(network: dict,
                          netmask: dict,
                          ):
    """
    validates if network and netmask are coherent
    this validator must be set to netmask option
    """
    if None in [network['value'], netmask['value']]:
        return
    try:
        ip_network(f'{network["value"]}/{netmask["value"]}')
    except ValueError as err:
        raise ValueError(_(f'network "{network["value"]}" ({network["name"]}) does not match '
                           'with this netmask')) from err


@function_waiting_for_dict
def valid_ip_netmask(ip: dict,  # pylint: disable=invalid-name
                     netmask: dict,
                     ):
    """validates if ip and netmask are coherent
    this validator must be set to netmask option
    """
    if None in [ip['value'], netmask['value']]:
        return
    ip_netmask = ip_interface(f'{ip["value"]}/{netmask["value"]}')
    if ip_netmask.ip == ip_netmask.network.network_address:
        msg = _(f'IP "{ip["value"]}" ({ip["name"]}) with this netmask is '
                'in fact a network address')
        raise ValueError(msg)
    if ip_netmask.ip == ip_netmask.network.broadcast_address:
        msg = _(f'IP "{ip["value"]}" ({ip["name"]}) with this netmask is '
                'in fact a broacast address')
        raise ValueError(msg)


@function_waiting_for_dict
def valid_broadcast(network: dict,
                    netmask: dict,
                    broadcast: dict,
                    ):
    """validates if the broadcast is coherent with network and netmask
    """
    if None in [network['value'], netmask['value'], broadcast['value']]:
        return
    if ip_network(f'{network["value"]}/{netmask["value"]}').broadcast_address != \
            ip_address(broadcast['value']):
        msg = _(f'broadcast invalid with network {network["value"]} ({network["name"]}) '
                f'and netmask {netmask["value"]} ({netmask["name"]})')
        raise ValueError(msg)


@function_waiting_for_dict
def valid_in_network(ip: dict,  # pylint: disable=invalid-name
                     network: dict,
                     netmask=Optional[dict],
                     ):
    """validates if an IP is in a network
    this validator must be set to ip option
    """
    if None in [ip['value'], network['value']]:
        return
    if '/' in network['value']:
        # it's a CIDR network
        network_value = network['value']
    else:
        if netmask is None or netmask['value'] is None:
            return
        network_value = f'{network["value"]}/{netmask["value"]}'
    network_obj = ip_network(network_value)
    ip_netmask = ip_interface(f'{ip["value"]}/{network_obj.netmask}')
    if ip_netmask not in network_obj:
        if netmask is None:
            msg = _('this IP is not in network {network["value"]} ({network["name"]})')
        else:
            msg = _('this IP is not in network {network["value"]} ({network["name"]}) '
                    'with netmask {netmask["value"]} ({netmask["name"]})')
        raise ValueError(msg)
    # test if ip is not network/broadcast IP
    if ip_netmask.ip == ip_netmask.network.network_address:
        msg = _(f'this IP with the network {network["value"]} ({network["value"]} '
                'is in fact a network address')
        raise ValueError(msg)
    if ip_netmask.ip == ip_netmask.network.broadcast_address:
        msg = _(f'this IP with the network {network["value"]} ({network["value"]} '
                'is in fact a broadcast address')
        raise ValueError(msg)


@function_waiting_for_dict
def valid_not_equal(*values):
    """valid that two options have not same value
    """
    equal = set()
    for val in values[1:]:
        if 'propertyerror' in val:
            continue
        if values[0]['value'] == val['value'] is not None:
            equal.add(val['name'])
    if not equal:
        return
    msg = _(f'value is identical to {display_list(list(equal), add_quote=True)}')
    raise ValueError(msg)


class CalcValue:
    """class to calc_value with different functions
    """
    # pylint: disable=too-many-instance-attributes
    def __call__(self,
                 *args: List[Any],
                 multi: bool=False,
                 default: Any=undefined,
                 condition: Any=undefined,
                 no_condition_is_invalid: bool=False,
                 expected: Any=undefined,
                 condition_operator: str='AND',
                 reverse_condition: bool=False,
                 allow_none: bool=False,
                 remove_duplicate_value: bool=False,
                 join: Optional[str]=None,
                 min_args_len: Optional[int]=None,
                 operator: Optional[str]=None,
                 index: Optional[int]=None,
                 **kwargs) -> Any:
        # pylint: disable=too-many-statements,too-many-branches,too-many-nested-blocks,too-many-locals
        """calculate value
        :param args: list of value
        :param multi: value returns must be a list of value
        :param default: default value if condition is not matched or if args is empty
                        if there is more than one default value, set default_0, default_1, ...
        :param condition: test if condition is equal to expected value
                          if there is more than one condition, set condition_0, condition_1, ...
        :param expected: value expected for all conditions
                         if expected value is different between condition, set expected_0,
                         expected_1, ...
        :param no_condition_is_invalid: if no condition and not condition_0, condition_1, ... (for
                                        example if option is disabled) consider that condition not
                                        matching
        :param condition_operator: OR or AND operator for condition
        :param allow_none: if False, do not return list in None is present in list
        :param remove_duplicate_value: if True, remote duplicated value
        :param join: join all args with specified characters
        :param min_args_len: if number of arguments is smaller than this value, return default value
        :param operator: 'add', 'mul', 'div' or 'sub' all args (args must be integer value)
        :param index: index for follower

        examples:
        * you want to copy value from an option to an other option:
        >>> from tiramisu import calc_value, StrOption, OptionDescription, Config, \
        ...                      Params, ParamOption
        >>> val1 = StrOption('val1', '', 'val1')
        >>> val2 = StrOption('val2', '', callback=calc_value,
        ...                  callback_params=Params(ParamOption(val1)))
        >>> od = OptionDescription('root', '', [val1, val2])
        >>> cfg = Config(od)
        >>> cfg.value.dict()
        {'val1': 'val1', 'val2': 'val1'}

        * you want to copy values from two options in one multi option
        >>> from tiramisu import calc_value, StrOption, OptionDescription, Config, Params, \
        ...                      ParamOption, ParamValue
        >>> val1 = StrOption('val1', "", 'val1')
        >>> val2 = StrOption('val2', "", 'val2')
        >>> val3 = StrOption('val3', "", multi=True, callback=calc_value,
        ...                  callback_params=Params((ParamOption(val1), ParamOption(val2)),
        ...                  multi=ParamValue(True)))
        >>> od = OptionDescription('root', '', [val1, val2, val3])
        >>> cfg = Config(od)
        >>> cfg.value.dict()
        {'val1': 'val1', 'val2': 'val2', 'val3': ['val1', 'val2']}

        * you want to copy a value from an option if it not disabled, otherwise set 'default_value'
        >>> from tiramisu import calc_value, StrOption, OptionDescription, Config, Params, \
        ...                      ParamOption, ParamValue
        >>> val1 = StrOption('val1', '', 'val1')
        >>> val2 = StrOption('val2', '', callback=calc_value,
        ...                  callback_params=Params(ParamOption(val1, True),
        ...                  default=ParamValue('default_value')))
        >>> od = OptionDescription('root', '', [val1, val2])
        >>> cfg = Config(od)
        >>> cfg.property.read_write()
        >>> cfg.value.dict()
        {'val1': 'val1', 'val2': 'val1'}
        >>> cfg.option('val1').property.add('disabled')
        >>> cfg.value.dict()
        {'val2': 'default_value'}

        * you want to copy value from an option if an other is True, otherwise set 'default_value'
        >>> from tiramisu import calc_value, BoolOption, StrOption, OptionDescription, Config, \
        ...                      Params, ParamOption, ParamValue
        >>> boolean = BoolOption('boolean', '', True)
        >>> val1 = StrOption('val1', '', 'val1')
        >>> val2 = StrOption('val2', '', callback=calc_value,
        ...                  callback_params=Params(ParamOption(val1, True),
        ...                                         default=ParamValue('default_value'),
        ...                                         condition=ParamOption(boolean),
        ...                                         expected=ParamValue(True)))
        >>> od = OptionDescription('root', '', [boolean, val1, val2])
        >>> cfg = Config(od)
        >>> cfg.property.read_write()
        >>> cfg.value.dict()
        {'boolean': True, 'val1': 'val1', 'val2': 'val1'}
        >>> cfg.option('boolean').value.set(False)
        >>> cfg.value.dict()
        {'boolean': False, 'val1': 'val1', 'val2': 'default_value'}

        * you want to copy option even if None is present
        >>> from tiramisu import calc_value, StrOption, OptionDescription, Config, Params, \
        ...                      ParamOption, ParamValue
        >>> val1 = StrOption('val1', "", 'val1')
        >>> val2 = StrOption('val2', "")
        >>> val3 = StrOption('val3', "", multi=True, callback=calc_value,
        ...                  callback_params=Params((ParamOption(val1), ParamOption(val2)),
        ...                  multi=ParamValue(True), allow_none=ParamValue(True)))
        >>> od = OptionDescription('root', '', [val1, val2, val3])
        >>> cfg = Config(od)
        >>> cfg.value.dict()
        {'val1': 'val1', 'val2': None, 'val3': ['val1', None]}

        * you want uniq value
        >>> from tiramisu import calc_value, StrOption, OptionDescription, Config, Params, \
        ...                      ParamOption, ParamValue
        >>> val1 = StrOption('val1', "", 'val1')
        >>> val2 = StrOption('val2', "", 'val1')
        >>> val3 = StrOption('val3', "", multi=True, callback=calc_value,
        ...                  callback_params=Params((ParamOption(val1), ParamOption(val2)),
        ...                  multi=ParamValue(True), remove_duplicate_value=ParamValue(True)))
        >>> od = OptionDescription('root', '', [val1, val2, val3])
        >>> cfg = Config(od)
        >>> cfg.value.dict()
        {'val1': 'val1', 'val2': 'val1', 'val3': ['val1']}

        * you want to join two values with '.'
        >>> from tiramisu import calc_value, StrOption, OptionDescription, Config, Params, \
        ...                      ParamOption, ParamValue
        >>> val1 = StrOption('val1', "", 'val1')
        >>> val2 = StrOption('val2', "", 'val2')
        >>> val3 = StrOption('val3', "", callback=calc_value,
        ...                  callback_params=Params((ParamOption(val1),
        ...                  ParamOption(val2)), join=ParamValue('.')))
        >>> od = OptionDescription('root', '', [val1, val2, val3])
        >>> cfg = Config(od)
        >>> cfg.value.dict()
        {'val1': 'val1', 'val2': 'val2', 'val3': 'val1.val2'}

        * you want join three values, only if almost three values are set
        >>> from tiramisu import calc_value, StrOption, OptionDescription, Config, Params, \
        ...                      ParamOption, ParamValue
        >>> val1 = StrOption('val1', "", 'val1')
        >>> val2 = StrOption('val2', "", 'val2')
        >>> val3 = StrOption('val3', "", 'val3')
        >>> val4 = StrOption('val4', "", callback=calc_value,
        ...                              callback_params=Params((ParamOption(val1),
        ...                                                      ParamOption(val2),
        ...                                                      ParamOption(val3, True)),
        ...                              join=ParamValue('.'), min_args_len=ParamValue(3)))
        >>> od = OptionDescription('root', '', [val1, val2, val3, val4])
        >>> cfg = Config(od)
        >>> cfg.property.read_write()
        >>> cfg.value.dict()
        {'val1': 'val1', 'val2': 'val2', 'val3': 'val3', 'val4': 'val1.val2.val3'}
        >>> cfg.option('val3').property.add('disabled')
        >>> cfg.value.dict()
        {'val1': 'val1', 'val2': 'val2', 'val4': ''}

        * you want to add all values
        >>> from tiramisu import calc_value, IntOption, OptionDescription, Config, Params, \
        ...                      ParamOption, ParamValue
        >>> val1 = IntOption('val1', "", 1)
        >>> val2 = IntOption('val2', "", 2)
        >>> val3 = IntOption('val3', "", callback=calc_value,
        ...                              callback_params=Params((ParamOption(val1),
                                                                 ParamOption(val2)),
        ...                              operator=ParamValue('add')))
        >>> od = OptionDescription('root', '', [val1, val2, val3])
        >>> cfg = Config(od)
        >>> cfg.value.dict()
        {'val1': 1, 'val2': 2, 'val3': 3}

        """
        # pylint: disable=attribute-defined-outside-init
        self.args = args
        self.condition = condition
        self.expected = expected
        self.condition_operator = condition_operator
        self.reverse_condition = reverse_condition
        self.kwargs = kwargs
        self.no_condition_is_invalid = no_condition_is_invalid  # pylint: disable=attribute-defined-outside-init
        value = self.get_value(default,
                               min_args_len,
                               )
        if not multi:
            if join is not None:
                if None not in value:
                    value = join.join(value)
                else:
                    value = None
            elif value and operator:
                new_value = value[0]
                oper = {'mul': mul,
                        'add': add,
                        'div': truediv,
                        'sub': sub,
                        }[operator]
                for val in value[1:]:
                    new_value = oper(new_value, val)
                value = new_value
            elif value == []:
                value = None
            else:
                value = value[0]
                if isinstance(value, list) and index is not None:
                    if len(value) > index:
                        value = value[index]
                    else:
                        value = None
        else:
            if join is not None:
                if None not in value:
                    length_val = None
                    for val in value:
                        if isinstance(val, list):
                            if None in val:
                                length_val = None
                                break
                            lval = len(val)
                            if length_val is not None and length_val != lval:
                                msg = _('unexpected value in calc_value with join attribute '
                                      f'"{val}" with invalid length "{length_val}"')
                                raise ValueError(msg)
                            length_val = lval
                    new_value = []
                    if length_val is not None:
                        for idx in range(length_val):
                            idx_val = []
                            for val in value:
                                if isinstance(val, list):
                                    idx_val.append(val[idx])
                                else:
                                    idx_val.append(val)
                            new_value.append(join.join(idx_val))
                    value = new_value
                else:
                    value = []
            elif None in value and not allow_none:
                value = []
            if remove_duplicate_value:
                new_value = []
                for val in value:
                    if val not in new_value:
                        new_value.append(val)
                value = new_value
        return value

    def value_from_kwargs(self,
                          value: Any,
                          pattern: str,
                          to_dict: bool=False,
                          empty_test=undefined) -> Any:
        """get value from kwargs
        """
        # pylint: disable=too-many-branches
        # if value attribute exist return it's value
        # otherwise pattern_0, pattern_1, ...
        # otherwise undefined
        if value is not empty_test:
            if to_dict == 'all':
                returns = {None: value}
            else:
                returns = value
        else:
            kwargs_matches = {}
            len_pattern = len(pattern)
            for key, pattern_value in self.kwargs.items():
                if key.startswith(pattern):
                    index = int(key[len_pattern:])
                    if isinstance(pattern_value, dict):
                        pattern_value = pattern_value['value']
                    kwargs_matches[index] = pattern_value
            if not kwargs_matches:
                returns = undefined
            else:
                keys = sorted(kwargs_matches)
                if to_dict:
                    returns = {}
                else:
                    returns = []
                for key in keys:
                    if to_dict:
                        returns[key] = kwargs_matches[key]
                    else:
                        returns.append(kwargs_matches[key])
        return returns

    def is_condition_matches(self,
                             condition_value,
                             ):
        """verify the condition
        """
        # pylint: disable=too-many-branches
        calculated_conditions = self.value_from_kwargs(condition_value,
                                                       'condition_',
                                                       to_dict='all',
                                                       )
        if calculated_conditions is undefined:
            is_matches = not self.no_condition_is_invalid
        else:
            is_matches = None
            calculated_expected = self.value_from_kwargs(self.expected,
                                                         'expected_',
                                                         to_dict=True,
                                                         )
            calculated_reverse = self.value_from_kwargs(self.reverse_condition,
                                                        'reverse_condition_',
                                                        to_dict=True,
                                                        empty_test=False,
                                                        )
            for idx, calculated_condition in calculated_conditions.items():
                if isinstance(calculated_expected, dict):
                    if idx is not None:
                        if isinstance(calculated_expected[idx], list):
                            current_matches = calculated_condition in calculated_expected[idx]
                        else:
                            current_matches = calculated_condition == calculated_expected[idx]
                    else:
                        current_matches = calculated_condition in calculated_expected.values()
                else:
                    current_matches = calculated_condition == calculated_expected
                if isinstance(calculated_reverse, dict) and idx in calculated_reverse:
                    reverse_condition = calculated_reverse[idx]
                else:
                    reverse_condition = False
                if is_matches is None:
                    is_matches = current_matches
                if self.condition_operator == 'AND':
                    is_matches = is_matches and current_matches
                    if reverse_condition:
                        is_matches = not is_matches
                    if not is_matches:
                        break
                elif self.condition_operator == 'OR':
                    is_matches = is_matches or current_matches
                    if reverse_condition:
                        is_matches = not is_matches
                    if is_matches:
                        break
                else:
                    msg = _(f'unexpected {self.condition_operator} condition_operator '
                            'in calc_value')
                    raise ValueError(msg)
            is_matches = is_matches and not self.reverse_condition \
                         or not is_matches and self.reverse_condition
        return is_matches

    def get_value(self,
                  default,
                  min_args_len,
                  ):
        """get the value from arguments
        """
        # retrieve the condition
        if isinstance(self.condition, dict):
            if 'value' in self.condition:
                condition_value = self.condition['value']
            else:
                condition_value = undefined
        else:
            condition_value = self.condition
        # value is empty if condition doesn't match
        # otherwise value is arg
        if not self.is_condition_matches(condition_value):
            value = []
        else:
            value = self.get_args()
        if min_args_len and not len(value) >= min_args_len:
            value = []
        if not value:
            # default value
            new_default = self.value_from_kwargs(default,
                                                 'default_',
                                                 )
            if new_default is not undefined:
                if not isinstance(new_default, list):
                    value = [new_default]
                else:
                    value = new_default
        return value

    def get_args(self):
        """get all arguments
        """
        return list(self.args)


class CalcValuePropertyHelp(CalcValue):
    """special class to display property error
    """
    def get_name(self):
        """get the condition name
        """
        return self.condition['name']

    def get_indexed_name(self, index: int) -> str:
        """get name for a specified index
        """
        condition_index = self.kwargs.get(f'condition_{index}')
        if condition_index is not None and not isinstance(condition_index, dict):
            raise ValueError(_(f'unexpected condition_{index} must have "todict" argument'))
        return condition_index['name']


    def build_property_message(self,
                               name: str,
                               value: Any,
                               ) -> str:
        """prepare message to display error message if needed
        """
        if not self.reverse_condition:
            msg = _('the value of "{0}" is {1}').format(name, value)
        else:
            msg = _('the value of "{0}" is not {1}').format(name, value)
        return msg

    def get_args(self):
        args = super().get_args()
        action = args[0]
        calculated_expected = self.value_from_kwargs(self.expected,
                                                     'expected_',
                                                     to_dict=True)
        if self.condition is not undefined:
            if 'propertyerror' in self.condition:
                msg = self.condition['propertyerror']
            else:
                name = self.get_name()
                if isinstance(calculated_expected, dict):
                    calc_values = calculated_expected.values()
                else:
                    calc_values = [calculated_expected]
                display_value = display_list([str(val) for val in calc_values],
                                             'or',
                                             add_quote=True)
                msg = self.build_property_message(name, display_value)
        else:
            msgs = []
            for key, value in calculated_expected.items():
                name = self.get_indexed_name(key)
                msgs.append(self.build_property_message(name, f'"{value}"'))
            msg = display_list(msgs, self.condition_operator.lower())
        return [(action, f'"{action}" ({msg})')]


calc_value = CalcValue()
calc_value.__name__ = 'calc_value'  # pylint: disable=attribute-defined-outside-init
# function_waiting_for_dict(calc_value)
calc_value_property_help = CalcValuePropertyHelp()
calc_value_property_help.__name__ = 'calc_value_property_help'  # pylint: disable=attribute-defined-outside-init
function_waiting_for_dict(calc_value_property_help)
