from itertools import permutations

import numpy as np

__all__ = ["auto_assign_coord_positions"]


def auto_assign_coord_positions(ax):
    """
    Given a ``WCSAxes`` instance, automatically update any dynamic tick, tick
    label and axis label positions.

    This function operates in-place on the axes and assumes that
    ``_update_ticks`` has already been called on all the ``CoordinateHelper``
    instances.
    """
    # Since ticks, tick labels and axis labels can all be auto or fixed, we need
    # a few rules to decide in what order to process things:
    #
    # * We should deal with axis labels last - if they are set to auto they
    #   should be made to match the tick labels.
    # * If ticks and tick labels are both set to automatic, then we first
    #   process tick labels and at the end we can make ticks match tick labels.
    # * We should use existing fixed tick label assignments to exclude certain
    #   spines.
    # * Fixed tick positions should not exclude other labels from being placed
    #   on that same spine since multiple different ticks might appear on the
    #   same axis.
    # * If ticks are not auto and tick labels are auto then tick labels should be
    #   placed at a spine containing ticks.

    # Start off by simplifying some cases that don't require algorithmic
    # positioning. First, if tick labels are shown at fixed positions, we
    # should just adjust any auto ticks and axis labels to match.
    for coords in ax._all_coords:
        for coord in coords:
            if "#" not in (pos := coord.get_ticklabel_position()):
                if "#" in coord.get_ticks_position():
                    coord.set_ticks_position(pos + ["#"])
                if "#" in coord.get_axislabel_position():
                    coord.set_axislabel_position(pos + ["#"])

    # At this point, all coordinates requiring automatic placement have
    # tick labels requiring automatic placement (any coordinates with fixed
    # tick label placement are dealt with). We construct a list of these
    # coordinates and also keep track of spines on which tick labels are
    # already fixed.

    auto_coords = []
    already_used = []
    for coords in ax._all_coords:
        for coord in coords:
            pos = coord.get_ticklabel_position()
            if "#" in pos:
                auto_coords.append(coord)
            else:
                already_used += list(pos)

    # If there are no more coordinates with auto settings, we are all done
    if len(auto_coords) == 0:
        return

    # Extract the spines for the frame
    spines = coords.frame._spine_auto_position_order

    # Construct a new list of spines taking into account excluded ones
    spines = "".join(s for s in spines if s not in already_used)

    # We create an iterable of different assignments of spines to
    # coords, where empty string means the coordinate will not be shown
    # on any axis.
    if len(auto_coords) > len(spines):
        spines = spines + "".join(" " * (len(auto_coords) - len(spines)))
    options = permutations(spines, r=len(auto_coords))

    # We now loop through and check different assignments based on the options
    # and try and find the one that maximises the number of tick labels in the
    # overall plot.
    n_tick_max = -1
    best_option = None
    for option in options:
        # Check if option is consistent with any fixed tick positions - that
        # is, if ticks were explicitly requested on axes say b and t then we
        # shouldn't automatically put tick labels on say l or r.
        consistent = True
        for c, s in zip(auto_coords, option):
            pos = c.get_ticks_position()
            if "#" not in pos and s not in pos:
                consistent = False
                break
        if not consistent:
            continue

        # Determine the number of tick labels on each axis
        n_on_each = {s: len(c._ticks.world[s]) for c, s in zip(auto_coords, option)}

        # Determine the total number of tick labels
        n_tick = sum(n_on_each.values())

        # Determine a sorted version of this list by spine axis order
        n_on_each_sorted = [n_on_each.get(s, 0) for s in spines]

        # We should ideally not have empty spines, so if there are any non-zero
        # values in n_on_each then we should add a penalty for every 0 value
        # that occurs before the last non-zero value
        idx = np.nonzero(n_on_each_sorted)[0]
        if len(idx) > 0:
            n_tick -= n_on_each_sorted[: idx[-1]].count(0)

        # Keep track of the best option so far
        if n_tick > n_tick_max:
            n_tick_max = n_tick
            best_option = option

    # Finalize assignments
    for coord, spine in zip(auto_coords, best_option):
        position = [spine, "#"] if spine != " " else "#"
        if "#" in coord.get_ticks_position():
            coord.set_ticks_position(position)
        if "#" in coord.get_ticklabel_position():
            coord.set_ticklabel_position(position)
        if "#" in coord.get_axislabel_position():
            coord.set_axislabel_position(position)
