# Copyright (c) 2023 Cisco and/or its affiliates.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at:
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Module defining LoadRounding class."""

import math

from dataclasses import dataclass
from typing import List, Tuple

from .dataclass import secondary_field


@dataclass
class LoadRounding:
    """Class encapsulating stateful utilities that round intended load values.

    For MLRsearch algorithm logic to be correct, it is important that
    interval width expansion and narrowing are exactly reversible,
    which is not true in general for floating point number arithmetics.

    This class offers conversion to and from an integer quantity.
    Operations in the integer realm are guaranteed to be reversible,
    so the only risk is when converting between float and integer realm.

    Which relative width corresponds to the unit integer
    is computed in initialization from width goals,
    striking a balance between memory requirements and precision.

    There are two quality knobs. One restricts how far
    can an integer be from the exact float value.
    The other restrict how close it can be. That is to make sure
    even with unpredictable rounding errors during the conversion,
    the converted integer value is never bigger than the intended float value,
    to ensure the intervals returned from MLRsearch will always
    meet the relative width goal.

    An instance of this class is mutable only in the sense it contains
    a growing cache of previously computed values.
    """

    # TODO: Hide the cache and present as frozen hashable object.

    min_load: float
    """Minimal intended load [tps] to support, must be positive."""
    max_load: float
    """Maximal intended load [tps] to support, must be bigger than min load."""
    float_goals: Tuple[float]
    """Relative width goals to approximate, each must be positive
    and smaller than one. Deduplicated and sorted in post init."""
    quality_lower: float = 0.99
    """Minimal multiple of each goal to be achievable."""
    quality_upper: float = 0.999999
    """Maximal multiple of each goal to be achievable."""
    # Primary fields above, computed fields below.
    max_int_load: int = secondary_field()
    """Integer for max load (min load int is zero)."""
    _int2load: List[Tuple[int, float]] = secondary_field()
    """Known int values (sorted) and their float equivalents."""

    def __post_init__(self) -> None:
        """Ensure types, perform checks, initialize conversion structures.

        :raises RuntimeError: If a requirement is not met.
        """
        self.min_load = float(self.min_load)
        self.max_load = float(self.max_load)
        if not 0.0 < self.min_load < self.max_load:
            raise RuntimeError("Load limits not supported: {self}")
        self.quality_lower = float(self.quality_lower)
        self.quality_upper = float(self.quality_upper)
        if not 0.0 < self.quality_lower < self.quality_upper < 1.0:
            raise RuntimeError("Qualities not supported: {self}")
        goals = []
        for goal in self.float_goals:
            goal = float(goal)
            if not 0.0 < goal < 1.0:
                raise RuntimeError(f"Goal width {goal} is not supported.")
            goals.append(goal)
        self.float_goals = tuple(sorted(set(goals)))
        self.max_int_load = self._find_ints()
        self._int2load = []
        self._int2load.append((0, self.min_load))
        self._int2load.append((self.max_int_load, self.max_load))

    def _find_ints(self) -> int:
        """Find and return value for max_int_load.

        Separated out of post init, as this is less conversion and checking,
        and more math and searching.

        A dumb implementation would start with 1 and kept increasing by 1
        until all goals are within quality limits.
        An actual implementation is smarter with the increment,
        so it is expected to find the resulting values somewhat faster.

        :returns: Value to be stored as max_int_load.
        :rtype: int
        """
        minmax_log_width = math.log(self.max_load) - math.log(self.min_load)
        log_goals = [-math.log1p(-goal) for goal in self.float_goals]
        candidate = 1
        while 1:
            log_width_unit = minmax_log_width / candidate
            # Fallback to increment by one if rounding errors make tries bad.
            next_tries = [candidate + 1]
            acceptable = True
            for log_goal in log_goals:
                units = log_goal / log_width_unit
                int_units = math.floor(units)
                quality = int_units / units
                if not self.quality_lower <= quality <= self.quality_upper:
                    acceptable = False
                    target = (int_units + 1) / self.quality_upper
                    next_try = (target / units) * candidate
                    next_tries.append(next_try)
                # Else quality acceptable, not bumping the candidate.
            if acceptable:
                return candidate
            candidate = int(math.ceil(max(next_tries)))

    def int2float(self, int_load: int) -> float:
        """Convert from int to float tps load. Expand internal table as needed.

        Too low or too high ints result in min or max load respectively.

        :param int_load: Integer quantity to turn back into float load.
        :type int_load: int
        :returns: Converted load in tps.
        :rtype: float
        :raises RuntimeError: If internal inconsistency is detected.
        """
        if int_load <= 0:
            return self.min_load
        if int_load >= self.max_int_load:
            return self.max_load
        lo_index, hi_index = 0, len(self._int2load)
        lo_int, hi_int = 0, self.max_int_load
        lo_load, hi_load = self.min_load, self.max_load
        while hi_int - lo_int >= 2:
            mid_index = (hi_index + lo_index + 1) // 2
            if mid_index >= hi_index:
                mid_int = (hi_int + lo_int) // 2
                log_coeff = math.log(hi_load) - math.log(lo_load)
                log_coeff *= (mid_int - lo_int) / (hi_int - lo_int)
                mid_load = lo_load * math.exp(log_coeff)
                self._int2load.insert(mid_index, (mid_int, mid_load))
                hi_index += 1
            mid_int, mid_load = self._int2load[mid_index]
            if mid_int < int_load:
                lo_index, lo_int, lo_load = mid_index, mid_int, mid_load
                continue
            if mid_int > int_load:
                hi_index, hi_int, hi_load = mid_index, mid_int, mid_load
                continue
            return mid_load
        raise RuntimeError("Bisect in int2float failed.")

    def float2int(self, float_load: float) -> int:
        """Convert and round from tps load to int. Maybe expand internal table.

        Too low or too high load result in zero or max int respectively.

        Result value is rounded down to an integer.

        :param float_load: Tps quantity to convert into int.
        :type float_load: float
        :returns: Converted integer value suitable for halving.
        :rtype: int
        """
        if float_load <= self.min_load:
            return 0
        if float_load >= self.max_load:
            return self.max_int_load
        lo_index, hi_index = 0, len(self._int2load)
        lo_int, hi_int = 0, self.max_int_load
        lo_load, hi_load = self.min_load, self.max_load
        while hi_int - lo_int >= 2:
            mid_index = (hi_index + lo_index + 1) // 2
            if mid_index >= hi_index:
                mid_int = (hi_int + lo_int) // 2
                log_coeff = math.log(hi_load) - math.log(lo_load)
                log_coeff *= (mid_int - lo_int) / (hi_int - lo_int)
                mid_load = lo_load * math.exp(log_coeff)
                self._int2load.insert(mid_index, (mid_int, mid_load))
                hi_index += 1
            mid_int, mid_load = self._int2load[mid_index]
            if mid_load < float_load:
                lo_index, lo_int, lo_load = mid_index, mid_int, mid_load
                continue
            if mid_load > float_load:
                hi_index, hi_int, hi_load = mid_index, mid_int, mid_load
                continue
            return mid_int
        return lo_int
