import copy
import math
import random

import numpy as np

from base_optimizer.optimizer_common import *
from base_optimizer.smopt_feederpriority import *
from base_optimizer.result_analysis import *


# TODO: nozzle tool available restriction
# TODO: consider with the PCB placement topology
def assembly_time_estimator(assignment_points, arranged_feeders, component_data):
    nozzle_heads, nozzle_points = defaultdict(int), defaultdict(int)
    for idx, points in enumerate(assignment_points):
        if points == 0:
            continue
        nozzle_points[component_data.iloc[idx]['nz']] += points
        nozzle_heads[component_data.iloc[idx]['nz']] = 1

    while sum(nozzle_heads.values()) != max_head_index:
        max_cycle_nozzle = None

        for nozzle, head_num in nozzle_heads.items():
            if max_cycle_nozzle is None or nozzle_points[nozzle] / head_num > nozzle_points[max_cycle_nozzle] / \
                    nozzle_heads[max_cycle_nozzle]:
                max_cycle_nozzle = nozzle

        assert max_cycle_nozzle is not None
        nozzle_heads[max_cycle_nozzle] += 1

    head_nozzle_assignment, min_cost = None, None

    # generate initial nozzle group
    nozzle_group = []
    # averagely assign for the same type of nozzles, and generate nozzle group
    nozzle_points_cpy = copy.deepcopy(nozzle_points)
    for nozzle, heads in nozzle_heads.items():
        points = nozzle_points_cpy[nozzle] // heads
        for _ in range(heads):
            nozzle_group.append([nozzle, points])
        nozzle_points_cpy[nozzle] -= heads * points

    for idx, [nozzle, _] in enumerate(nozzle_group):
        if nozzle_points_cpy[nozzle]:
            nozzle_group[idx][1] += 1
            nozzle_points_cpy[nozzle] -= 1

    while True:
        # assign nozzle group to each head
        nozzle_group.sort(key=lambda x: -x[1])

        tmp_head_nozzle_assignment = []
        head_total_points = [0 for _ in range(max_head_index)]
        for idx, nozzle_item in enumerate(nozzle_group):
            if idx < max_head_index:
                tmp_head_nozzle_assignment.append([nozzle_item.copy()])
                head_total_points[idx] += nozzle_item[1]
            else:
                min_head = np.argmin(head_total_points)
                tmp_head_nozzle_assignment[min_head].append(nozzle_item.copy())
                head_total_points[min_head] += nozzle_item[1]

        cost = t_cycle * max(head_total_points)
        for head in range(max_head_index):
            for cycle in range(len(tmp_head_nozzle_assignment[head])):
                if cycle + 1 == len(tmp_head_nozzle_assignment[head]):
                    if tmp_head_nozzle_assignment[head][cycle][0] != tmp_head_nozzle_assignment[head][-1][0]:
                        cost += t_nozzle_change
                else:
                    if tmp_head_nozzle_assignment[head][cycle][0] != tmp_head_nozzle_assignment[head][cycle + 1][0]:
                        cost += t_nozzle_change

        while True:
            min_head, max_head = np.argmin(head_total_points), np.argmax(head_total_points)
            min_head_nozzle, max_head_nozzle = tmp_head_nozzle_assignment[min_head][-1][0], \
                                               tmp_head_nozzle_assignment[max_head][-1][0]
            if min_head_nozzle == max_head_nozzle:
                break

            min_head_list, max_head_list = [min_head], [max_head]
            minmax_head_points = 0
            for head in range(max_head_index):
                if head in min_head_list or head in max_head_list:
                    minmax_head_points += head_total_points[head]
                    continue

                # the max/min heads with the sum nozzle type
                if tmp_head_nozzle_assignment[head][-1][0] == tmp_head_nozzle_assignment[min_head][-1][0]:
                    min_head_list.append(head)
                    minmax_head_points += head_total_points[head]

                if tmp_head_nozzle_assignment[head][-1][0] == tmp_head_nozzle_assignment[max_head][-1][0]:
                    max_head_list.append(head)
                    minmax_head_points += head_total_points[head]

            # todo: restriction of available nozzle
            # the reduction of cycles is not offset the cost of nozzle change
            average_points = minmax_head_points // (len(min_head_list) + len(max_head_list))
            reminder_points = minmax_head_points % (len(min_head_list) + len(max_head_list))
            max_cycle = average_points + (1 if reminder_points > 0 else 0)
            for head in range(max_head_index):
                if head in min_head_list or head in max_head_list:
                    continue
                max_cycle = max(max_cycle, head_total_points[head])

            nozzle_change_counter = 0
            for head in min_head_list:
                if tmp_head_nozzle_assignment[head][0] == tmp_head_nozzle_assignment[head][-1]:
                    nozzle_change_counter += 2
                else:
                    nozzle_change_counter += 1

            if t_cycle * (max(head_total_points) - max_cycle) < t_nozzle_change * nozzle_change_counter:
                break

            cost -= t_cycle * (max(head_total_points) - max_cycle) - t_nozzle_change * nozzle_change_counter

            required_points = 0  # 待均摊的贴装点数较多的吸嘴类型
            for head in min_head_list:
                points = average_points - head_total_points[head]
                tmp_head_nozzle_assignment[head].append([max_head_nozzle, points])
                head_total_points[head] = average_points
                required_points += points

            for head in max_head_list:
                tmp_head_nozzle_assignment[head][-1][1] -= required_points // len(max_head_list)
                head_total_points[head] -= required_points // len(max_head_list)

            required_points -= (required_points // len(max_head_list)) * len(max_head_list)

            for head in max_head_list:
                if required_points <= 0:
                    break
                tmp_head_nozzle_assignment[head][-1][1] -= 1
                head_total_points[head] -= 1
                required_points -= 1

        if min_cost is None or cost < min_cost:
            min_cost = cost
            head_nozzle_assignment = copy.deepcopy(tmp_head_nozzle_assignment)
        else:
            break

        # 在吸嘴组中增加一个吸嘴
        idx, nozzle = 0, nozzle_group[0][0]
        for idx, [nozzle_, _] in enumerate(nozzle_group):
            if nozzle_ != nozzle:
                break

        average_points, remainder_points = nozzle_points[nozzle] // (idx + 1), nozzle_points[nozzle] % (idx + 1)
        nozzle_group.append([nozzle, 0])
        for idx, [nozzle_, _] in enumerate(nozzle_group):
            if nozzle_ == nozzle:
                nozzle_group[idx][1] = average_points + (1 if remainder_points > 0 else 0)
                remainder_points -= 1

    cycle_counter, nozzle_change_counter = 0, 0
    for head in range(max_head_index):
        head_cycle_counter = 0
        for cycle in range(len(head_nozzle_assignment[head])):
            if cycle + 1 == len(head_nozzle_assignment[head]):
                if head_nozzle_assignment[head][0][0] != head_nozzle_assignment[head][-1][0]:
                    nozzle_change_counter += 1
            else:
                if head_nozzle_assignment[head][cycle][0] != head_nozzle_assignment[head][cycle + 1][0]:
                    nozzle_change_counter += 1
            head_cycle_counter += head_nozzle_assignment[head][cycle][1]
        cycle_counter = max(cycle_counter, head_cycle_counter)

    # === 元件拾取次数预估 ===
    cp_info = []
    for idx, points in enumerate(assignment_points):
        if points == 0:
            continue
        feeder_limit = int(component_data.iloc[idx].fdn)
        reminder_points = points % feeder_limit
        for _ in range(feeder_limit):
            cp_info.append(
                [idx, points // feeder_limit + (1 if reminder_points > 0 else 0), component_data.iloc[idx].nz])
            reminder_points -= 1

    cp_info.sort(key=lambda x: -x[1])

    nozzle_level, nozzle_counter = defaultdict(int), defaultdict(int)
    level_points = defaultdict(int)
    for info in cp_info:
        nozzle = info[2]
        if nozzle_counter[nozzle] and nozzle_counter[nozzle] % nozzle_heads[nozzle] == 0:
            nozzle_level[nozzle] += 1
        level = nozzle_level[nozzle]
        level_points[level] = max(level_points[level], info[1])
        nozzle_counter[nozzle] += 1

    pickup_counter = sum(points for points in level_points.values())
    placement_counter = sum(assignment_points)

    pickup_movement = 0
    for points in assignment_points:
        if points:
            pickup_movement += 1
    # 返回加权预估时间
    return t_cycle * cycle_counter + t_nozzle_change * nozzle_change_counter + t_pick * pickup_counter + \
        t_place * placement_counter + 0.1 * pickup_movement


def line_optimizer_heuristic(component_data, machine_number):
    # the number of placement points, the number of available feeders, and nozzle type of component respectively
    component_number = len(component_data)

    nozzle_points = defaultdict(int)        # the number of placements of nozzle
    total_points = 0
    for _, data in component_data.iterrows():
        nozzle = data['nz']
        nozzle_points[nozzle] += data['points']
        total_points += data['point']

    # first step: generate the initial solution with equalized workload
    assignment_result = [[0 for _ in range(component_number)] for _ in range(machine_number)]
    assignment_points = [0 for _ in range(machine_number)]
    average_points = total_points // machine_number

    weighted_points = list(
        map(lambda _, data: data['points'] + 1e-5 * nozzle_points[data['nz']], component_data.iterrows()))

    # for part_index in np.argsort(weighted_points)[::-1]:
    for part_index in np.argsort(weighted_points)[::-1]:
        if (total_points := component_data.iloc[part_index]['points']) == 0: # total placements for each component type
            continue
        machine_set = []

        # define the machine that assigning placement points (considering the feeder limitation)
        for machine_index in np.argsort(assignment_points):
            if len(machine_set) >= component_data.iloc[part_index].points or len(machine_set) >= \
                    component_data.iloc[part_index].fdn:
                break

            machine_set.append(machine_index)
            if weighted_points[part_index] + assignment_points[machine_index] < average_points:
                break

        # Allocation of mounting points to available machines according to the principle of equality
        while total_points:
            assign_machine = list(filter(lambda x: assignment_points[x] == min(assignment_points), machine_set))

            if len(assign_machine) == len(machine_set):
                # averagely assign point to all available machines
                points = total_points // len(assign_machine)
                for machine_index in machine_set:
                    assignment_points[machine_index] += points
                    assignment_result[machine_index][part_index] += points

                total_points -= points * len(assign_machine)
                for machine_index in machine_set:
                    if total_points == 0:
                        break
                    assignment_points[machine_index] += 1
                    assignment_result[machine_index][part_index] += 1
                    total_points -= 1
            else:
                # assigning placements to make up for the gap between the least and the second least
                second_least_machine, second_least_machine_points = -1, max(assignment_points) + 1
                for idx in machine_set:
                    if assignment_points[idx] < second_least_machine_points and assignment_points[idx] != min(
                            assignment_points):
                        second_least_machine_points = assignment_points[idx]
                        second_least_machine = idx

                assert second_least_machine != -1

                if len(assign_machine) * (second_least_machine_points - min(assignment_points)) < total_points:
                    min_points = min(assignment_points)
                    total_points -= len(assign_machine) * (second_least_machine_points - min_points)
                    for machine_index in assign_machine:
                        assignment_points[machine_index] += (second_least_machine_points - min_points)
                        assignment_result[machine_index][part_index] += (
                                    second_least_machine_points - min_points)
                else:
                    points = total_points // len(assign_machine)
                    for machine_index in assign_machine:
                        assignment_points[machine_index] += points
                        assignment_result[machine_index][part_index] += points

                    total_points -= points * len(assign_machine)
                    for machine_index in assign_machine:
                        if total_points == 0:
                            break
                        assignment_points[machine_index] += 1
                        assignment_result[machine_index][part_index] += 1
                        total_points -= 1

    prev_max_assembly_time, prev_assignment_result = None, None
    while True:
        # second step: estimate the assembly time for each machine
        arranged_feeders = defaultdict(list)
        for machine_index in range(machine_number):
            arranged_feeders[machine_index] = [0 for _ in range(component_number)]

        for part_index in range(component_number):
            feeder_limit = component_data.iloc[part_index].fdn    # 总体可用数
            for machine_index in range(machine_number):
                if assignment_result[machine_index][part_index] == 0:
                    continue
                feeder_limit -= 1
                # 已分配元件的机器至少安装1把供料器
                arranged_feeders[machine_index][part_index] = 1
                assert feeder_limit >= 0

        for part_index in range(component_number):
            total_feeder_limit = component_data.iloc[part_index].fdn - sum(
                [arranged_feeders[machine_index][part_index] for machine_index in range(machine_number)])
            while total_feeder_limit > 0:
                max_ratio, max_ratio_machine = None, -1
                for machine_index in range(machine_number):
                    if assignment_result[machine_index][part_index] == 0:
                        continue
                    ratio = assignment_result[machine_index][part_index] / arranged_feeders[machine_index][part_index]
                    if max_ratio is None or ratio > max_ratio:
                        max_ratio, max_ratio_machine = ratio, machine_index
                assert max_ratio_machine is not None
                arranged_feeders[max_ratio_machine][part_index] += 1
                total_feeder_limit -= 1

        assembly_time, chip_per_hour = [], []
        for machine_index in range(machine_number):
            assembly_time.append(
                assembly_time_estimator(assignment_result[machine_index], arranged_feeders[machine_index],
                                        component_data))
            chip_per_hour.append(sum(assignment_result[machine_index]) / (assembly_time[-1] + 1e-10))

        max_assembly_time = max(assembly_time)
        if prev_max_assembly_time and (prev_max_assembly_time < max_assembly_time or abs(
                max_assembly_time - prev_max_assembly_time) < 1e-10):
            if prev_max_assembly_time < max_assembly_time:
                assignment_result = copy.deepcopy(prev_assignment_result)
            break
        else:
            prev_max_assembly_time = max_assembly_time
            prev_assignment_result = copy.deepcopy(assignment_result)

        # third step: adjust the assignment results to reduce maximal assembly time among all machines
        # ideal averagely assigned points
        average_assign_points = [round(total_points * chip_per_hour[mi] / sum(chip_per_hour)) for mi in
                                 range(machine_number)]

        machine_index = 0
        while total_points != sum(average_assign_points):
            if total_points > sum(average_assign_points):
                average_assign_points[machine_index] += 1
            else:
                average_assign_points[machine_index] -= 1
            machine_index += 1
            if machine_index >= machine_number:
                machine_index = 0

        # the placement points that need to be re-allocated
        machine_reallocate_points = [sum(assignment_result[mi]) - average_assign_points[mi] for mi in
                                     range(machine_number)]

        # workload balance
        # 1. balance the number of placements of the same type between different machines.
        for demand_mi in range(machine_number):
            if machine_reallocate_points[demand_mi] >= 0:
                continue

            supply_machine_list = [mi for mi in range(machine_number) if machine_reallocate_points[mi] > 0]
            supply_machine_list.sort(key=lambda mi: -machine_reallocate_points[mi])

            for supply_mi in supply_machine_list:
                for part_index in range(component_number):
                    if assignment_result[supply_mi][part_index] <= 0:
                        continue

                    reallocate_points = min(assignment_result[supply_mi][part_index],
                                            -machine_reallocate_points[demand_mi])

                    # upper available feeder restrictions
                    tmp_reallocate_result = [assignment_result[mi][part_index] for mi in range(machine_number)]
                    tmp_reallocate_result[supply_mi] -= reallocate_points
                    tmp_reallocate_result[demand_mi] += reallocate_points

                    if sum(1 for pt in tmp_reallocate_result if pt > 0) > component_data.iloc[part_index].fdn:
                        continue

                    assignment_result[supply_mi][part_index] -= reallocate_points
                    machine_reallocate_points[supply_mi] -= reallocate_points
                    assignment_result[demand_mi][part_index] += reallocate_points
                    machine_reallocate_points[demand_mi] += reallocate_points

                    if machine_reallocate_points[demand_mi] <= 0:
                        break

        # 2. balance the number of placements of the different type between different machines.
        cp_info = []
        for part_index in range(component_number):
            for machine_index in range(machine_number):
                if assignment_result[machine_index][part_index] == 0:
                    continue
                cp_info.append([machine_index, part_index, assignment_result[machine_index][part_index]])

        for machine_index in range(machine_number):
            if machine_reallocate_points[machine_index] >= 0:
                continue

            filter_cp_info = [info for info in cp_info if
                              info[0] != machine_index and machine_reallocate_points[info[0]] > 0]
            while True:
                if len(filter_cp_info) == 0 or machine_reallocate_points[machine_index] >= 0:
                    break
                # todo: 对同时拾取数的影响
                filter_cp_info.sort(key=lambda x: x[2] + machine_reallocate_points[machine_index])

                info = filter_cp_info[0]
                filter_cp_info.remove(info)
                if abs(machine_reallocate_points[machine_index]) + abs(machine_reallocate_points[info[0]]) < abs(
                        machine_reallocate_points[machine_index] + info[2]) + abs(
                        machine_reallocate_points[info[0]] - info[2]):
                    continue

                cp_info.remove(info)

                assignment_result[info[0]][info[1]] = 0
                assignment_result[machine_index][info[1]] += info[2]

                machine_reallocate_points[info[0]] -= info[2]
                machine_reallocate_points[machine_index] += info[2]

    return assignment_result
