import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
from brokenaxes import brokenaxes

def find_pareto_optimal_points(latencies, mpq_accuracies, mpq_confs):
    """
    Finds the Pareto optimal points given latencies and accuracies.

    Args:
        latencies (numpy.ndarray): An array of latency values.
        accuracies (numpy.ndarray): An array of accuracy values.
        mpq_confs (numpy.ndarray): An array with the sorted weight_bit_width per layer list based on
                                    estimated latency

    Returns:
        tuple: A tuple containing the following:
            - numpy.ndarray: Array of Pareto optimal points (Latency, Accuracy).
            - numpy.ndarray: Indices of Pareto optimal accuracies in the original array.
    """
    data = {
        'Configuration': [mpq_confs[idx].astype(int) for idx in range(len(mpq_confs))],
        'Accuracy': mpq_accuracies,
        'MACC Instrs': latencies
    }

    # Combine latencies, accuracies, and indices
    combined = np.column_stack((latencies, mpq_accuracies, np.arange(len(mpq_accuracies))))

    # Initialize the Pareto front with the first point
    pareto_front = [combined[0]]
    for pair in combined[1:]:
        # Check if the accuracy of the current pair is better than the last point in the Pareto front
        if pair[1] > pareto_front[-1][1]:
            pareto_front.append(pair)

    # Extract Pareto points and indices
    pareto_points_with_indices = np.array(pareto_front)
    pareto_points = pareto_points_with_indices[:, :2]
    pareto_indices = pareto_points_with_indices[:, 2].astype(int)

    # Set print options for clarity
    np.set_printoptions(precision = 2, suppress = True)
    
        # Create DataFrame
    data = {
        'Index': pareto_indices,
        'Weights Resolutions': [mpq_confs[idx].astype(int) for idx in pareto_indices],
        'Accuracy': [point[1] for point in pareto_points],
        'MACC Instrs': [point[0] for point in pareto_points]
    }

    df = pd.DataFrame(data)

    return df, pareto_points, pareto_indices

def identify_best_solution(pareto_solutions_df, fp_accuracy, maximum_acc_loss = 1):
    threshold = fp_accuracy - maximum_acc_loss
    filtered_df = pareto_solutions_df[pareto_solutions_df['Accuracy'] >= threshold]
    filtered_df = filtered_df.sort_values(by = 'MACC Instrs', ascending=True)

    if not filtered_df.empty:
        best_config = filtered_df.iloc[0]  # Get the first row
        print(f"\nBest Configuration Details with Maximum Accuracy loss < {maximum_acc_loss}%:")
        print(f"Index: {best_config['Index']}")
        print(f"Weights Resolutions: {best_config['Weights Resolutions']}")
        print(f"Accuracy: {best_config['Accuracy']:.2f}%")
        print(f"MACC Instrs: {best_config['MACC Instrs']}")
    else:
        print("No configurations meet the specified accuracy threshold.")

    return best_config

def plot_pareto_solutions(mpq_mac, fp_mac_instr, accuracy, 
                          fp_accuracy, pareto_points, name):
    """
    Plots Pareto solutions based on MAC instructions, accuracy, and other parameters.

    Args:
        mpq_mac (numpy.ndarray): Array of MAC instructions for mixed precision layer configurations.
        fp_mac_instr (float): Original (non-optimized) model's MAC instructions.
        accuracy (numpy.ndarray): Array of accuracy values for mixed precision layer configurations.
        fp_accuracy (float): Original (non-optimized) model's accuracy.
        pareto_points (numpy.ndarray): Array of Pareto optimal points (Latency, Accuracy).

    Returns:
        None: Displays the plot and saves it as a PNG file.
    """
    # Define the x-axis and y-axis ranges

    ranges = [(0, max(mpq_mac) + 0.5 * max(mpq_mac)), 
              (fp_mac_instr - 0.15 * fp_mac_instr, fp_mac_instr + 0.15 * fp_mac_instr)]

    # Calculate the exponent for notation
    exponent = int(np.log10(fp_mac_instr))
    notation = 10 ** exponent

    # Create the figure and broken axes
    fig = plt.figure(figsize = (6, 4))
    fig.subplots_adjust(left = 0.148, right = 0.88, top = 0.95, bottom = 0.2)
    bax = brokenaxes(xlims = ranges, hspace = 0.05)

    # Scatter plot for original model, mixed precision, and Pareto points
    bax.scatter(fp_mac_instr, fp_accuracy, color = 'black', s = 9, marker = '*', 
                label = 'original model')

    bax.scatter(mpq_mac, accuracy, color = 'gray', marker = 'o', s = 9, 
                label = 'mixed configs')
    
    bax.scatter(pareto_points[:, 0], pareto_points[:, 1], color = 'green', marker = 's', s = 9,
                label = 'pareto points')

    # Customize the plot
    bax.set_ylabel('Accuracy (%)', fontsize = 10, labelpad = 35)
    bax.grid(True)

    # Set the y-axis limit if provided
    
    y_lims = [max(50, 0.8 * np.min(accuracy)), min(1.1 * fp_accuracy, 100)]
    bax.set_ylim(y_lims[0], y_lims[1])

    # Place the legend
    bax.legend(loc = 'upper center', bbox_to_anchor = (0.5, 1.2), ncol = 3, fontsize = 10)

    # Format x-axis labels
    sFormatter1 = ticker.FuncFormatter(lambda x, _: '{:0.2f}'.format(x / notation))
    sFormatter2 = ticker.ScalarFormatter(useOffset = False, useMathText = True)
    sFormatter2.set_powerlimits((0, 2))
    
    bax.axs[0].xaxis.set_major_formatter(sFormatter1)
    bax.axs[1].xaxis.set_major_formatter(sFormatter2)

    bax.axs[0].tick_params(axis = 'y', labelsize = 10)
    bax.axs[0].tick_params(axis = 'x', labelsize = 10)
    bax.axs[1].tick_params(axis = 'x', labelsize = 10)

    bax.axs[1].xaxis.get_offset_text().set_position((1, 0))
    bax.axs[1].xaxis.get_offset_text().set_fontsize(10)

    # Reposition the x-axis label
    bax.set_xlabel('MAC Instructions', ha = 'center', fontsize = 10, labelpad = 25)

    plt.savefig(name + ".png")

def pareto_space(fp_accuracy, test_accuracy, weights_per_layer, macc_per_layer, total_macc_opt_sorted, name):
    df, pareto_points, _ = find_pareto_optimal_points(np.array(total_macc_opt_sorted),
                                        np.array(test_accuracy), np.array(weights_per_layer))

    optimal_config = identify_best_solution(df, np.array(fp_accuracy))
    
    plot_pareto_solutions(np.array(total_macc_opt_sorted), 
                          sum(macc_per_layer), np.array(test_accuracy), 
                          float(fp_accuracy), pareto_points, name)
    
    optimal_config = optimal_config.iloc[1]

    return optimal_config.tolist()
