import math
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Tuple

from service_capacity_modeling.interface import CapacityDesires
from service_capacity_modeling.interface import CapacityPlan
from service_capacity_modeling.interface import ExcuseTag
from service_capacity_modeling.interface import normalized_aws_size


def current_instance_name(desires: CapacityDesires) -> Optional[str]:
    """Return the current cluster's instance name, or None if not set.

    Checks zonal clusters first, then regional. Used by the planner to
    tag excuses relative to the current shape.
    """
    cc = desires.current_clusters
    if not cc:
        return None
    cur = cc.zonal[0] if cc.zonal else (cc.regional[0] if cc.regional else None)
    return cur.cluster_instance.name if cur and cur.cluster_instance else None


def compute_excuse_tags(
    excuse_instance: str,
    current_instance: Optional[str],
) -> List[ExcuseTag]:
    """Tag an excuse relative to the current cluster's instance type.

    Returns tags classifying the excuse instance vs the current shape:
    current_shape, same_family+size_up/down, or different_family.
    Returns [] when there is no current cluster to compare against.
    """
    if current_instance is None:
        return []
    if excuse_instance == current_instance:
        return [ExcuseTag.current_shape]
    excuse_family = excuse_instance.rsplit(".", 1)[0]
    current_family = current_instance.rsplit(".", 1)[0]
    if excuse_family == current_family:
        direction = (
            ExcuseTag.size_up
            if normalized_aws_size(excuse_instance)
            > normalized_aws_size(current_instance)
            else ExcuseTag.size_down
        )
        return [ExcuseTag.same_family, direction]
    return [ExcuseTag.different_family]


def reduce_by_family(
    plans: Iterable[CapacityPlan], max_results_per_family: int = 1
) -> List[CapacityPlan]:
    """Groups a potential set of clusters by hardware family sorted by cost.

    Useful for showing different family options.

    Args:
        plans: Iterable of CapacityPlan objects to filter
        max_results_per_family: Maximum number of results to return per
            family combination
    """
    zonal_families: Dict[Tuple[Tuple[str, str], ...], int] = {}
    regional_families: Dict[Tuple[Tuple[str, str], ...], int] = {}

    result: List[CapacityPlan] = []
    for plan in plans:
        topo = plan.candidate_clusters
        regional_type: Tuple[Tuple[str, str], ...] = tuple()
        zonal_type: Tuple[Tuple[str, str], ...] = tuple()

        if topo.regional:
            regional_type = tuple(
                sorted({(c.cluster_type, c.instance.family) for c in topo.regional})
            )

        if topo.zonal:
            zonal_type = tuple(
                sorted({(c.cluster_type, c.instance.family) for c in topo.zonal})
            )

        # Count how many of each family combination we've seen
        zonal_count = zonal_families.get(zonal_type, 0)
        regional_count = regional_families.get(regional_type, 0)

        # Add the plan if we haven't reached the maximum for either family type
        if (
            zonal_count < max_results_per_family
            or regional_count < max_results_per_family
        ):
            result.append(plan)

            # Update counters
            zonal_families[zonal_type] = zonal_count + 1
            regional_families[regional_type] = regional_count + 1

    return result


# https://stackoverflow.com/questions/14267555/find-the-smallest-power-of-2-greater-than-or-equal-to-n-in-python
def next_power_of_2(y: float) -> int:
    x = int(y)
    return 1 if x == 0 else 2 ** (x - 1).bit_length()


def is_power_of_2(y: float) -> bool:
    """Check if x is a power of 2 or 1"""
    return y == next_power_of_2(y)


def next_doubling(x: float, base: int) -> int:
    # Some clusters were provisioned as non powers of (e.g. 12)
    # And so if a requirement cannot be satisifed by a cluster
    # we would want to round up to the next doubling
    # E.g. 12 -> 24 -> 48
    # e.g. 3 -> 6 -> 12
    if x <= base:
        return int(base)
    return int(base * (2 ** math.ceil(math.log(x / base, 2))))


def next_n(x: float, n: float) -> int:
    return int(math.ceil(x / n)) * int(n)
