import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from num2words import num2words
import re
import mplcursors
import math
import itertools

def check_kinetic_data(kinetic_data, bold_and_red=True, print_report=True):
    """
    Checks whether the kinetic data loaded has the right format for auto_vtna and creates a report
    string with relevant information and warnings.

    The function checks that:
    - kinetic_data is a dictionary
    - each value in the dictionary is a pandas DataFrame
    - no DataFrame is empty
    - column titles are consistent across experiment DataFrames
    - there are enough columns for VTNA
    - all time/concentration values are numerical
    - no negative values are present
    - the time column is strictly monotonically increasing
    - column titles are strings and not numeric-like

    Expected input format:
        kinetic_data = pd.read_excel("file_name.xlsx", sheet_name=None)

    Alternatively, each experiment can be loaded from separate CSV files and collected into a dict.
    """
    import math
    import re
    import numbers
    import numpy as np
    import pandas as pd
    from num2words import num2words
    def all_elements_not_convertible_to_float(my_list):
        def is_float_convertible(x):
            try:
                float(x)
                return True
            except (TypeError, ValueError):
                return False
        return all(not is_float_convertible(element) for element in my_list)

    def count_convertible_to_float(my_list):
        count = 0
        for element in my_list:
            try:
                float(element)
                count += 1
            except (TypeError, ValueError):
                pass
        return count

    def remove_escape_sequences(input_string):
        escape_sequence_pattern = re.compile(r"\x1b\[[0-9;]*m")
        cleaned_string = escape_sequence_pattern.sub("", input_string)
        return cleaned_string

    error = None
    tick_symbol = "\u2713"

    report = (
        "Kinetic data check report.\n"
        "--------------------------\n"
        "\033[1mCheck 1: Correct loading of kinetic data from Excel.\033[0m\n"
    )

    # Check that kinetic_data is a dictionary
    if not isinstance(kinetic_data, dict):
        report += (
            "\033[91mOBS:\033[0m The kinetic data is not loaded correctly. It should "
            "be a dictionary containing the different experiment datasets as dataframes.\n"
            "Make sure that the excel file contains one experiment dataset per\n"
            "sheet with column titles for time and concentration of reaction species and load "
            "the data like this: pd.read_excel('file_name.xlsx', sheet_name=None)\n"
        )
        report += (
            "\033[1mConclusion: the kinetic_data variable is not ready for use with the "
            "auto_vtna package.\033[0m"
        )
        if bold_and_red is False:
            report = remove_escape_sequences(report)
        if print_report:
            print(report)
        return report

    report += f"  {tick_symbol} The kinetic data is loaded as a dictionary.\n"

    # Check that all elements are pandas DataFrames
    if not all(isinstance(df, pd.DataFrame) for df in kinetic_data.values()):
        report += (
            "\033[91mOBS:\033[0m Some of the elements in the kinetic data dictionary are "
            "not pandas dataframe objects. This will need to be fixed.\n"
            "Make sure the excel file contains one experiment dataset per\n"
            "sheet with column titles for time and concentration of reaction species "
            "and load the data like this: pd.read_excel('file_name.xlsx', sheet_name=None)\n"
        )
        report += (
            "\033[1mConclusion: the kinetic_data variable is not ready for use with "
            "the auto_vtna package.\033[0m"
        )
        if bold_and_red is False:
            report = remove_escape_sequences(report)
        if print_report:
            print(report)
        return report

    report += (
        f"  {tick_symbol} Each element associated a key in the kinetic data dictionary "
        f"is a pandas dataframe object.\n"
    )

    # Check that no DataFrames are empty
    has_empty_dataframes = any(df.empty for df in kinetic_data.values())
    if has_empty_dataframes:
        report += (
            "\033[91mOBS:\033[0m One or more experiment datasets are empty.\n"
            "Make sure that there are no empty sheets and that the excel file contains one "
            "experiment dataset per\nsheet with column titles for time and concentration of "
            "reaction species.\n"
        )
        report += (
            "\033[1mConclusion: the kinetic_data variable is not ready for use with the "
            "auto_vtna package.\033[0m"
        )
        if bold_and_red is False:
            report = remove_escape_sequences(report)
        if print_report:
            print(report)
        return report

    no_experiments = len(list(kinetic_data.keys()))

    # Collect column info
    column_titles = []
    no_columns = []
    columns = []

    for exp in kinetic_data.keys():
        cols = kinetic_data[exp].columns.tolist()
        column_titles.append(cols)
        no_columns.append(len(cols))
        columns.append(cols)

    # Check if only one experiment
    if no_experiments == 1:
        error = True
        report += (
            "\033[91mOBS:\033[0m The kinetic data only contains data from one "
            "experiment. This is not enough for VTNA.\n"
        )

    # Check column counts
    if len(no_columns) > 0 and all(i == no_columns[0] for i in no_columns):
        report += (
            f"  {tick_symbol} The dataframe element(s) in the kinetic data dictionary "
            f"all contain {no_columns[0]} columns.\n"
        )
        if no_columns[0] == 1:
            report += (
                "\033[91mOBS:\033[0m The dataframe elements in the kinetic data dictionary "
                "contain only one column. This is insufficient for VTNA.\n"
            )
            report += (
                "\033[1mConclusion: the kinetic data is not ready for use with the "
                "auto_vtna package.\033[0m"
            )
            if bold_and_red is False:
                report = remove_escape_sequences(report)
            if print_report:
                print(report)
            return report

    # Check 2
    report += "\033[1mCheck 2: Column title consistency.\033[0m\n"

    if len(no_columns) > 0 and all(i == no_columns[0] for i in no_columns):
        column_titles_array = np.array(column_titles, dtype=object)
        are_equal = np.all(column_titles_array == column_titles_array[0, :], axis=0)
        n_cols = len(column_titles_array[0])

        if all(are_equal):
            report += f"  {tick_symbol} All experiment datasets have the same column titles.\n"
        else:
            error = True
            for i in range(n_cols):
                if not are_equal[i]:
                    report += (
                        f"\033[91mOBS:\033[0m The {num2words(i + 1, to='ordinal_num')} "
                        f"column title is different across experiments: "
                        f"{column_titles_array[:, i]} \n"
                    )
    else:
        error = True
        report += (
            "\033[91mOBS:\033[0m the number of columns in the dataframe objects in "
            "kinetic_data are not consistent.\nThe column titles are:\n"
        )
        for i in range(len(column_titles)):
            report += f"  - {list(kinetic_data.keys())[i]}: {column_titles[i]}\n"

    # Check 3
    counter = 0
    report += "\033[1mCheck 3: Numerical datapoints.\033[0m \n"

    for exp in kinetic_data.keys():
        df = kinetic_data[exp]
        for column in list(df.columns):
            numeric_col = pd.to_numeric(df[column], errors="coerce")
            num_column = numeric_col.notna().tolist()

            if all(num_column):
                continue
            else:
                error = True
                counter += 1
                bad_rows = [index + 2 for index, value in enumerate(num_column) if value is False]
                report += "\033[91mOBS:\033[0m"
                report += (
                    f' Row(s) number {", ".join(map(str, bad_rows))} in column "{column}" '
                    f'in dataset "{exp}" contains values which are not numerical. '
                    f"Needs to be fixed.\n"
                )

    if counter == 0:
        report += f"  {tick_symbol} Every experiment dataset contains only numerical values.\n"

    # Check 4
    counter = 0
    report += "\033[1mCheck 4: Positive datapoints.\033[0m \n"

    for exp in kinetic_data.keys():
        df = kinetic_data[exp]
        for column in list(df.columns):
            numeric_col = pd.to_numeric(df[column], errors="coerce")
            any_negative_values = (numeric_col < 0).fillna(False).tolist()

            if not any(any_negative_values):
                continue
            else:
                error = True
                counter += 1
                bad_rows = [index + 2 for index, value in enumerate(any_negative_values) if value]
                report += "\033[91mOBS:\033[0m"
                report += (
                    f' Row(s) number {", ".join(map(str, bad_rows))} in column "{column}" '
                    f'in dataset "{exp}" contains negative values. Needs to be fixed.\n'
                )

    if counter == 0:
        report += (
            f"  {tick_symbol} No datasets contain negative time or concentration values.\n"
        )

    # Check 5
    report += "\033[1mCheck 5: Time column monotonicity.\033[0m\n"
    counter = 0

    for exp in kinetic_data.keys():
        time_col_name = kinetic_data[exp].columns[0]
        time_array = pd.to_numeric(kinetic_data[exp][time_col_name], errors="coerce").to_numpy()

        # Only evaluate monotonicity if all values are numeric
        if np.any(pd.isna(time_array)):
            report += (
                f"\033[91mOBS:\033[0m The time values in dataset {exp} could not be fully "
                f"interpreted as numerical, so monotonicity could not be confirmed.\n"
            )
            error = True
            counter += 1
        elif not np.all(time_array[1:] > time_array[:-1]):
            report += (
                f"\033[91mOBS:\033[0m The time values in dataset {exp} are not strictly "
                f"monotonically increasing. Needs to be fixed.\n"
            )
            error = True
            counter += 1

    if counter == 0:
        report += (
            f"  {tick_symbol} The time column (first column) of the dataset in every sheet "
            f"is monotonically increasing.\n"
        )

    # Check 6
    report += f"\033[1mCheck 6: Time and concentration columns.\033[0m \n"
    check_for_floats = 0

    columns_flat = []
    for col_list in columns:
        columns_flat.extend(col_list)

    # Check if all column titles are strings
    if all(isinstance(i, str) for i in columns_flat):
        report += (
            f"  {tick_symbol} All dataframe elements in the kinetic data dictionary "
            f"have column titles which are strings.\n"
        )
    else:
        for exp_no in range(len(list(kinetic_data.keys()))):
            if all_elements_not_convertible_to_float(columns[exp_no]):
                continue
            else:
                check_for_floats = 1
                report += (
                    f"\033[91mOBS:\033[0m {count_convertible_to_float(columns[exp_no])} "
                    f"of the column titles of dataframe "
                    f"{list(kinetic_data.keys())[exp_no]} are numerical values.\n"
                )
                error = True

    if check_for_floats == 0:
        report += (
            f"  {tick_symbol} The column titles of every experiment dataset are all strings.\n"
        )

    time_column_titles = [i[0] for i in columns]

    if len(time_column_titles) > 0 and all(i == time_column_titles[0] for i in time_column_titles):
        time_column_titles_display = time_column_titles[0]
    else:
        time_column_titles_display = time_column_titles

    report += (
        f"\033[91mNB:\033[0m Make sure that the time columns are the first columns in "
        f"each dataframe in kinetic_data. Currently, the first column titles are: "
        f"{time_column_titles_display}.\n"
    )

    # Conclusion
    if error is None:
        report += (
            "\033[1mConclusion:\033[0m The kinetic data has been loaded correctly if "
            "the time axis label is correct."
        )
    else:
        report += (
            "\033[91m\033[1mConclusion:\033[0m The kinetic data has not been loaded "
            "correctly. Make sure that the kinetic data Excel spreadsheet is organised "
            "correctly and try again using kinetic_data=pd.read_excel('file_name.xlsx', "
            "sheet_name=None)."
        )

    if bold_and_red is False:
        report = remove_escape_sequences(report)

    if print_report:
        print(report)

    return report

def make_VTNA_selection(data,normalised_species='NS',output_species='OS'):
    """
    Creates a default VTNA selection dictionary structure with keys for each \
    experiment name as well as the normalised_species and output_species. The \
    dictionary can then be copied by the user and information about the desired \
    VTNA filled in such as:
    - RM: Sub dictionary containing each experiment dataset (RM stands for reaction \
    mixture) with an associated list of omitted reaction species by list index. 
    - normalised_species: Sub dictionary containing information about which reaction \
    species' concentration profiles will be used to normalise the time axis. Each \
    normalised species has an associated reaction order value for time normalisation. 
    - output_species: The reaction species whose concentration profile will define \
    y-axis. 
    The keyword arguments normalised_species and output_species can be defined to \
    generate a VTNA selection dictionary with the relevant keys.
    """
    selection_dict={}
    selection_dict['RM']={}
    for i in data.keys():
        selection_dict['RM'][i]={}
        selection_dict['RM'][i]['omissions']=None
        if i==normalised_species:
            selection_dict['normalised_species']={}
            selection_dict['normalised_species'][i]=1
        if i==output_species:
            selection_dict['output_species']=output_species
    if output_species=='OS':
        selection_dict['output_species']={}
        selection_dict['output_species']='OS'
    if normalised_species=='NS':
        selection_dict['normalised_species']={}
        selection_dict['normalised_species']['NS']=1
    return selection_dict

def initial_concs(data):
    """
    Creates a Pandas DataFrame showing the initial concentrations of every reaction species across each \
    experiment. Requires that each experiment DataFrame in "data" have the same column titles.
    """
    # Obtain a list of the experiment names
    experiments=list(data.keys())
    # Obtain a list containing the reaction species with concentration profiles in the kinetic dataset.
    reaction_species=data[experiments[0]].columns[1:]
    # Loop through each experiment and obtain the intial concentration of each reaction species.
    initial_conc_dict={'Reaction species':reaction_species}
    for j in experiments:
        initial_conc_dict[f'{j} init. conc.']=data[j].iloc[0,1:].to_numpy()
    return pd.DataFrame(initial_conc_dict)

def plot_data(data, ylim=None, y_unit='M', t_unit=None, fig_size_scaler=1,plot_mode='together',
    legend_outside=False, DP_scaler=0.35,linewidth=0.7,show_legend=True):
    """
    Inputs the kinetic data "data" that has been imported from an excel file using pd.read_excel() \
    as a dictionary of DataFrames containing the concentration profiles for each different excess \
    experiment. Plots the concentration profiles of each each experiment in separate plots unless \
    plot_mode is set to 'together' or 'scrollable'.
    Args:
        data (dict): Kinetic data as a dictionary of Pandas dataframes.
        ylim (float, optional): Y axis upper cutoff value so that Y∈[0, ylim]. 
        y_unit (str, optional): Concentration unit to be included in the Y axis label. 
        t_unit (str, optional): Time unit to be included in the X axis label. 
        fig_size_scaler (float or int, optional): Scaler to adjust the size of the graphs by the \
        selected factor.
        plot_mode (str): Either set to "scrollable" to generate a single graph which updates \
        to the next or previous graph by clicking right or left arrow keys, or 'together' to \
        plot graphs for each experiment together. If plot_mode is set to something else, \
        graphs will pop-up one after the other. 
        legend_outside (bol): Set to False by default to show legend inside the axis of the plot. \
        Can be set to True to show the legend outside the axis (top right). 
        DP_scaler (float, int): Factor by which the datapoints of the plots is scaled from a standard \
        value of 6. 
        linewidth (float, int): The linewidth parameter used to generate plots. Is by default set to 1. 
    """
    # Make the plot mode 'separate' if there is only one experiment in the dataset. 
    if len(list(data.keys()))==1:
        plot_mode='separate'
    # Set the datapoint size scaler to 1 if an incorrect value has been assigned.
    if DP_scaler==None or isinstance(DP_scaler, str):
        DP_scaler=0.35
    if plot_mode=='together':
        addition=0
        if legend_outside:
            addition=1
        # Check how many rows and columns there should be based on the number of dataframes in data.
        n,m = len(list(data.keys()))+addition,3
        if n>6:
            m=4
        num_cols = min(m, n)
        num_rows = (n + num_cols - 1) // num_cols
        # Add an exception if there are 9 plot objects:
        if len(list(data.keys()))+addition==9:
            num_cols,num_rows=3,3
        # Create a single figure and subplots with specified number of rows and columns.
        # fig, axs = plt.subplots(nrows=num_rows, ncols=num_cols, figsize=(7 * 0.45*fig_size_scaler * num_cols, 5 * 0.6*num_rows * fig_size_scaler),constrained_layout=True)
        fig, axs = plt.subplots(nrows=num_rows, ncols=num_cols, figsize=(7 * 0.45*fig_size_scaler * num_cols, 5 * 0.6*num_rows * fig_size_scaler))
        for i, (rm_name, rm_data) in enumerate(data.items()):
            # Check if there's only one row
            if num_rows == 1:  
                ax = axs[i % num_cols]  # Use modulo to handle indexing in a 1D array
            else:
                row, col = divmod(i, num_cols)
                ax = axs[row, col]
            time = rm_data[rm_data.columns[0]]
            ax.set_title(rm_name)
            # Plot kinetic profiles for each reaction species for a given reaction mixture data set
            for j in rm_data.columns[1:]:
                ax.plot(time, rm_data[j], label=j, linestyle='-', marker='o',markersize=6*DP_scaler,linewidth=linewidth)
            ax.set_xlabel(rm_data.columns[0])
            # Add a unit to the time axis title is specified. 
            if t_unit is not None:
                ax.set_xlabel(f'{rm_data.columns[0]} / {t_unit}')
            ax.set_ylabel(f'Concentration / {y_unit}')
            # Define y-axis range up to ylim if defined.
            if ylim is not None:
                ax.set_ylim(0, ylim)
            # Add a legend if legend_outise is not set to true:
            if show_legend:
                if not legend_outside:
                    ax.legend()
        # Add an extra axis object if legend_outside is set to True. 
        if show_legend:
            if legend_outside:
                i=i+1
                if num_rows == 1:  # Check if there's only one row
                    ax = axs[i % num_cols]  # Use modulo to handle indexing in a 1D array
                    handles, labels = axs[0].get_legend_handles_labels()
                else:
                    row, col = divmod(i, num_cols)
                    ax = axs[row, col]
                    handles, labels = axs[0,0].get_legend_handles_labels()
                ax.spines['top'].set_visible(False)
                ax.spines['right'].set_visible(False)
                ax.spines['left'].set_visible(False)
                ax.spines['bottom'].set_visible(False)
                ax.set_xticklabels([])
                ax.set_yticklabels([])
                ax.set_xticks([])
                ax.set_yticks([])
                ax.legend(handles, labels, loc='center')
            # Remove empty subplots if there are any
            for i in range(len(data)+addition, num_cols * num_rows):
                fig.delaxes(axs.flatten()[i])
            plt.show()
    elif plot_mode=='scrollable':
        # Define a class for creating scrollable plots
        class ScrollablePlot:
            def __init__(self, data_dict, ylim=None, y_unit='M', t_unit=None, 
                         fig_size_scaler=1,legend_outside=legend_outside,DP_scaler=0.35,linewidth=0.7,show_legend=True):
                # Extract data and labels from the given dictionary
                data_1 = [data_dict[i] for i, j in data_dict.items()]
                data_labels = list(data_dict.keys())
                # Initialize instance variables
                self.data = data_1
                self.labels = data_labels
                self.current_plot_index = 0
                self.ylim = ylim
                self.y_unit = y_unit
                self.t_unit = t_unit
                self.fig_size_scaler = fig_size_scaler
                self.legend_outside=legend_outside
                self.DP_scaler=DP_scaler
                self.linewidth=linewidth
                self.show_legend=show_legend
                # Create a Matplotlib figure and axis
                self.fig, self.ax = plt.subplots()
                # Create a note to inform user of scrolling feature.
                note_text = 'NB: Use left or right arrows to scroll to other experiments.'
                self.fig.text(0.5, 0.015, note_text, ha='center', color='gray')
                # Plot the initial data
                self.plot_current_data()
                # Enable hovering using mplcursors
                mplcursors.cursor(hover=True)
                # Connect the key press event to the corresponding method
                self.fig.canvas.mpl_connect('key_press_event', self.on_key_press)

            def plot_current_data(self):
                # Clear the current axis
                self.ax.clear()
                # Get the data and label for the current plot
                current_data = self.data[self.current_plot_index]
                current_label = self.labels[self.current_plot_index]
                # Extract time and concentration data
                t = current_data.columns[0]
                time_column = current_data[t]
                # Plot each concentration profile
                for i in current_data.keys():
                    if i == t:
                        continue
                    self.ax.plot(time_column, current_data[i], label=i, marker='o',
                                 markersize=6*self.DP_scaler,linewidth=self.linewidth)
                # Set the figure size
                self.fig.set_size_inches(7 *0.8* self.fig_size_scaler, 5 *0.8* self.fig_size_scaler)
                # Set plot title
                self.ax.set_title(f'Concentration profiles in {current_label}')
                # Set x-axis label
                if self.t_unit is not None:
                    self.ax.set_xlabel(f'Time / {self.t_unit}')
                else:
                    self.ax.set_xlabel(t)
                # Set y-axis label
                self.ax.set_ylabel(f'Concentration / {self.y_unit}')
                # Set y-axis limit if specified
                if self.ylim is not None and isinstance(self.ylim, (float, int)):
                    self.ax.set_ylim(0, self.ylim)
                # Display legend
                if self.show_legend:
                    if self.legend_outside:
                        # Calculate the width of the legend dynamically based on the longest text element
                        legend = self.ax.legend()
                        # Obtain the legend width in display units. 
                        legend_bbox = legend.get_window_extent()
                        legend_width = legend_bbox.width
                        # Obtain the figure width in display units.
                        fig_box = plt.gcf().get_window_extent()
                        fig_width_display = fig_box.width
                        # See by what fraction the figure width needs to be extended for outside legend.
                        legend_width_scaled=legend_width/fig_width_display
                        # Scale the right argument according to the legend width
                        self.fig.subplots_adjust(right=1 - legend_width_scaled)
                        # Place the legend outside the axis
                        self.ax.legend(loc='center left',bbox_to_anchor=(1, 0.8))
                        # Adjust figure size to increase width according to the outside legend width
                        fig_size = plt.gcf().get_size_inches()
                        fig_size[0] = fig_size[0] + fig_size[0]*(legend_width_scaled) # Increase width by the padding
                        plt.gcf().set_size_inches(fig_size)
                    else:
                        self.ax.legend()
                    # Adjust layout to prevent overlapping of titles and labels
                plt.tight_layout()
                # Redraw the plot
                plt.draw()
            def on_key_press(self, event):
                # Handle key presses for navigating between plots
                if event.key == 'right':
                    self.next_plot()
                elif event.key == 'left':
                    self.previous_plot()
            def next_plot(self):
                # Move to the next plot and update the display
                self.current_plot_index = (self.current_plot_index + 1) % len(self.data)
                self.plot_current_data()
            def previous_plot(self):
                # Move to the previous plot and update the display
                self.current_plot_index = (self.current_plot_index - 1) % len(self.data)
                self.plot_current_data()
        scrollable_plot = ScrollablePlot(data, ylim=ylim, y_unit=y_unit, t_unit=t_unit, 
        fig_size_scaler=fig_size_scaler,legend_outside=legend_outside,DP_scaler=DP_scaler,linewidth=linewidth,show_legend=show_legend)
        plt.show()
    else:
            # Identify the first key in the data dictionary
        RM1=list(data.keys())[0]
        # Identify the time column unit for the first reaction mixture data set
        t=data[RM1].columns[0]
        for i in data.keys():
            plt.figure(figsize=(7*0.8*fig_size_scaler, 5*0.8*fig_size_scaler))
            time=data[i][t]
            # plot kinetic profiles for each reaction species for a given reaction mixture data set
            for j in data[i].keys():
                if j==t:
                    continue
                plt.plot(time,data[i][j],label=j,linestyle='-',marker='o',markersize=6*DP_scaler,linewidth=linewidth)
                plt.title(i)
                plt.xlabel(t)
                if t_unit!=None:
                    plt.xlabel(f'{t} / {t_unit}')
                plt.ylabel(f'Concentration / {y_unit}')
            # Define y-axis range up to ylim if defined. 
            if show_legend:
                if legend_outside:
                    # Calculate the width of the legend dynamically based on the longest text element
                    legend = plt.legend()
                    # Obtain the legend width in display units. 
                    legend_bbox = legend.get_window_extent()
                    legend_width = legend_bbox.width
                    # Obtain the figure width in display units.
                    fig_box = plt.gcf().get_window_extent()
                    fig_width_display = fig_box.width
                    # See by what fraction the figure width needs to be extended for outside legend.
                    legend_width_scaled=legend_width/fig_width_display
                    # Scale the right argument according to the legend width
                    plt.subplots_adjust(right=1 - legend_width_scaled)
                    # Place the legend outside the axis
                    plt.legend(loc='center left', bbox_to_anchor=(1, 0.8))
                    # Adjust figure size to increase width according to the outside legend width
                    fig_size = plt.gcf().get_size_inches()
                    fig_size[0] = fig_size[0] + fig_size[0]*(legend_width_scaled) # Increase width by the padding
                    plt.gcf().set_size_inches(fig_size)
                else:
                    plt.legend()
            if ylim!=None:
                plt.ylim(0,ylim)
            #Use plt.show to complete plot and move to the next reaction mixture data set
            plt.tight_layout()
            plt.show()

def plot_data_together(data, species, ylim=None, y_unit='mM', fig_size_scaler=1, t_unit=None,
                       legend_outside=False, DP_scaler=0.35, linewidth=0.7,show_legend=True):
    """
    Inputs the kinetic data "data" that has been imported from an excel file using Pd.read_excel() \
    as a dictionary of DataFrames containing the concentration profiles for each different excess \
    experiment. Creates a plot with the concentration profiles of the selected reaction species from \
    each experiment.
    """
    # Identify the first key in the data dictionary
    RM1 = list(data.keys())[0]
    # Identify the time column unit for the first reaction mixture data set
    t = data[RM1].columns[0]
    # Set the datapoint size scaler to 1 if an incorrect value has been assigned.
    if DP_scaler is None or isinstance(DP_scaler, str):
        DP_scaler = 0.35
    t_label = t
    if t_unit is not None:
        t_label = t_label + f' / {t_unit}'
    
    fig, ax = plt.subplots(figsize=(7 * 0.75 * fig_size_scaler, 5 * 0.75 * fig_size_scaler))
    
    # Define a colormap for assigning different colors to each experiment
    base_colors = plt.get_cmap('tab10')  # A colormap with distinct colors for each experiment
    
    # Define a set of markers to be used for each species within an experiment
    markers = ['^', 's', 'x', 'o', '*', 'd',]  # Simple, distinguishable markers
    
    # Iterate through each experiment
    for idx, i in enumerate(data.keys()):
        time = data[i][t]
        
        # Use a distinct color for each experiment
        base_color = base_colors(idx % 10)
        
        # Iterate through each species in the experiment
        for j_idx, j in enumerate(data[i].keys()):
            if j == t or j not in species:
                continue
            
            # Use different markers for each species within the same experiment
            marker = markers[j_idx % len(markers)]
            
            # Plot the concentration profile with the generated marker and color
            ax.plot(time, data[i][j], label=f'{i}, {j}', color=base_color,
                    linestyle='-', marker=marker, markersize=6 * DP_scaler, linewidth=linewidth)
        
        ax.set_title('Concentration profiles for selected species')
        ax.set_xlabel(t_label)
        ax.set_ylabel(f'Concentration / {y_unit}')
        
        # Define y-axis range up to ylim if defined.
        if ylim is not None:
            ax.set_ylim(0, ylim)
    
    # Add a custom legend entry explaining the labels
    handles, labels = ax.get_legend_handles_labels()
    handles.insert(0, plt.Line2D([], [], color='none', label='(Exp., Species)'))
    labels.insert(0, '(Exp., Species)')
    
    # Adjust legend placement based on user preference
    if show_legend:
        if legend_outside:
            legend = plt.legend(handles, labels)
            legend_bbox = legend.get_window_extent()
            legend_width = legend_bbox.width
            fig_box = plt.gcf().get_window_extent()
            fig_width_display = fig_box.width
            legend_width_scaled = legend_width / fig_width_display
            plt.subplots_adjust(right=1 - legend_width_scaled)
            plt.legend(handles, labels, loc='center left', bbox_to_anchor=(1, 0.8))
            fig_size = plt.gcf().get_size_inches()
            fig_size[0] = fig_size[0] + fig_size[0] * legend_width_scaled
            plt.gcf().set_size_inches(fig_size)
        else:
            ax.legend(handles, labels)
    
    plt.tight_layout()
    plt.show()

def plot_data_MB(data, species, stochiometry, scaler=1, ylim=None, t_unit=None, y_unit='mM', fig_size_scaler=1,
                 plot_mode='together',legend_outside=False,DP_scaler=0.35,linewidth=0.7,show_legend=True):
    """
    Inputs the kinetic data "data" that has been imported from an excel file using Pd.read_excel() \
    as a dictionary of DataFrames containing the concentration profiles for each different excess \
    experiment. Plots the concentration profiles of each each experiment with the mass balance relative \
    to time=0 of the chosen reaction species and their stochiometries. The graphs can be plotted in one \
    figure (plot_mode = 'together'), in separate pop-up figures (plot_mode='separate') or in a single \
    figure that can be updated upon 
    Args:
        data (dict): Kinetic data as a dictionary of Pandas dataframes.
        species (list): List containing the reaction species whose concentration profiles \
        will be used to calculate the mass balance. 
        stociometry (list): List containing the stociometries corresponding to the species \
        list.
        ylim (float or int, optional): Y axis upper cutoff value so that Y∈[0,ylim]. 
        y_unit (str, optional): Concentration unit to be included in the Y axis label.
        fig_size_scaler (float or int, optional): Scaler to adjust the size of the graphs by the \
        selected factor.
        plot_mode (str): Either set to "scrollable" to generate a single graph which updates \
        to the next or previous graph by clicking right or left arrow keys, or 'together' to \
        plot graphs for each experiment together. If plot_mode is set to something else, \
        graphs will pop-up one after the other. 
        legend_outside (bol): Set to False by default to show legend inside the axis of the plot. \
        Can be set to True to show the legend outside the axis (top right). 
        DP_scaler (float, int): Factor by which the datapoints of the plots is scaled from a standard \
        value of 6. 
        linewidth (float, int): The linewidth parameter used to generate plots. Is by default set to 1.
        show_legend: Set to True to show the legend. False to not show any legends. 
    """
    # Make the plot mode 'separate' if there is only one experiment in the dataset. 
    if len(list(data.keys()))==1:
        plot_mode='separate'
    # Find the title of the time column of the experiment datasets
    RM1 = list(data.keys())[0]
    t = data[RM1].columns[0]
    # Set the datapoint size scaler to 1 if an incorrect value has been assigned.
    if DP_scaler==None or isinstance(DP_scaler, str):
        DP_scaler=0.35
    MB_title_string = ''
    # Define the title of the mass balance axis
    for mol in range(len(species)):
        if mol != 0 and species[mol] == species[-1]:
            MB_title_string = MB_title_string + 'and '
        MB_title_string = MB_title_string + f'{species[mol]}, '
    if len(species) == 1:
        MB_title_string = f'{species[0]}. '
    if plot_mode=='together':
        addition=0
        if legend_outside:
            addition=1
        # Check how many rows and columns there should be based on the number of dataframes in data.
        n,m = len(list(data.keys()))+addition,3
        if n>6:
            m=4
        num_cols = min(m, n)
        num_rows = (n + num_cols - 1) // num_cols
        # Add an exception if there are 9 plot objects:
        if len(list(data.keys()))+addition==9:
            num_cols,num_rows=3,3
        # Create a single figure and subplots with specified number of rows and columns.
        # fig, axes = plt.subplots(nrows=num_rows, ncols=num_cols, figsize=(7.5 * 0.45*fig_size_scaler * num_cols, 4.5 * 0.6*num_rows * fig_size_scaler),constrained_layout=True)
        fig, axes = plt.subplots(nrows=num_rows, ncols=num_cols, figsize=(7.5 * 0.45*fig_size_scaler * num_cols, 4.5 * 0.6*num_rows * fig_size_scaler))
        axes = axes.flatten() 
        for idx, (i, ax) in enumerate(zip(data.keys(), axes)):
            Concs = []
            largest_conc = []
            time = data[i][t]
            # Create a curve for each reaction species
            for j in data[i].keys():
                if j == t:
                    continue
                ax.plot(time, data[i][j], label=j, linestyle='-', marker='o', markersize=6*DP_scaler,linewidth=linewidth)
                ax.set_title(i)
                ax.set_xlabel(t)
                ax.set_ylabel(f'concentration / {y_unit}')
                largest_conc.append(max(data[i][j].to_numpy()))
                if t_unit is not None:
                    ax.set_xlabel(f'{t} / {t_unit}')
                if j == t or j not in species:
                    continue
                # Add concentration*stochiometry array for calculation of mass balance over time
                Concs.append(data[i][j].to_numpy() * stochiometry[species.index(j)])
            # Use Concs to create mass balance sum for each time point
            sums = np.zeros(len(Concs[0]))
            for i in Concs:
                sums = sums + i
            # Use sums to find the % mass balance at each time relative to the initial value.
            # If the first sum is 0, use the final sum instead. (could occur if only product(s) are chosen)
            if sums[0]>0:
                MB = sums / sums[0] * 100 * scaler
            else:
                MB = sums / sums[-1] * 100 * scaler
            ax2 = ax.twinx()
            ax2.set_ylabel(f'Mass balance for:\n {MB_title_string[:-2]}', color="purple")
            ax2.plot(time, MB, label="Mass balance %", linestyle='-', marker='o', color="purple",markersize=6*DP_scaler)
            ax2.plot([0, max(time)], [100, 100], linestyle='--', marker='', color='purple')
            if show_legend:
                ax2.legend(loc='upper center')
            # Ensure appropriate y limits with space for legends.
            ax2.set_ylim(0, max(MB) + 28)
            if ylim is not None and type(ylim) in [float,int]:
                ax.set_ylim(0, ylim)
            else:
                ax.set_ylim(0, max(largest_conc) + max(largest_conc) * 0.15)
            if show_legend:
                if not legend_outside:
                    ax.legend()
        ax = axes[idx+1]
        handles, labels = axes[0].get_legend_handles_labels()
        ax.spines['top'].set_visible(False)
        ax.spines['right'].set_visible(False)
        ax.spines['left'].set_visible(False)
        ax.spines['bottom'].set_visible(False)
        ax.set_xticklabels([])
        ax.set_yticklabels([])
        ax.set_xticks([])
        ax.set_yticks([])
        if show_legend:
            ax.legend(handles, labels, loc='center left')
        for i in range(len(data)+addition, num_cols * num_rows):
          fig.delaxes(axes.flatten()[i])
        # Adjust layout to prevent overlap
        plt.tight_layout()
        plt.show()
    elif plot_mode=='scrollable':
        class ScrollablePlot:
            def __init__(self, data_dict,species, stochiometry,y_unit='M',t_unit=None,fig_size_scaler=1,
                         ylim=None,scaler=1,legend_outside=False,DP_scaler=0.35,linewidth=0.7,show_legend=True):
                data_1=[]
                for i,j in data_dict.items():
                    data_1.append(data_dict[i])
                data_labels=list(data_dict.keys())
                self.data = data_1
                self.labels = data_labels
                self.current_plot_index = 0
                self.y_unit=y_unit
                self.t_unit=t_unit
                self.ylim=ylim
                self.fig_size_scaler=fig_size_scaler
                self.species=species
                self.stochiometry=stochiometry
                self.MB_title_string=MB_title_string
                self.legend_outside=legend_outside
                self.DP_scaler=DP_scaler
                self.linewidth=linewidth
                self.show_legend=show_legend
                mplcursors.cursor(hover=True)
                self.fig, self.ax = plt.subplots(figsize=(7 *0.8* fig_size_scaler, 5 *0.8* fig_size_scaler))
                # Create a note to inform user of scrolling feature.
                note_text = 'NB: Use left or right arrows to scroll to other experiments.'
                self.fig.text(0.5, 0.015, note_text, ha='center', color='gray')
                self.ax2=self.ax.twinx()
                self.plot_current_data()
                self.fig.canvas.mpl_connect('key_press_event', self.on_key_press)

            def plot_current_data(self):
                self.ax.clear()
                self.ax2.clear()
                current_data = self.data[self.current_plot_index]
                current_label = self.labels[self.current_plot_index]
                t=current_data.columns[0]
                time_column=current_data[t]
                Concs=[]
                largest_conc=[]
                for j in current_data.keys():
                    if j==t:
                        continue
                    self.ax.plot(time_column,current_data[j],label=j,linestyle='-',marker='o',
                                 markersize=6*self.DP_scaler,linewidth=linewidth)
                    self.ax.set_title(f'Concentration profiles in {current_label}')
                    self.fig.set_size_inches(7*0.8*self.fig_size_scaler, 5*0.8*self.fig_size_scaler)
                    self.ax.set_xlabel(t)
                    self.ax.set_ylabel(f'concentration / {self.y_unit}')
                    largest_conc.append(max(current_data[j].to_numpy()))
                    if self.t_unit!=None:
                        self.ax.set_xlabel(f'Time / {self.t_unit}')
                    if j==t or j not in self.species:
                        continue
                    # Add concentration*stochiometry array for calculation of mass balance over time
                    Concs.append(current_data[j].to_numpy()*self.stochiometry[self.species.index(j)])
                # Use Concs to create mass balance sum for each time point
                sums=np.zeros(len(Concs[0]))
                for i in Concs:
                    sums=sums+i
                # Use sums to find the % mass balance at each time relative to the intial value.
                MB=sums/sums[0]*100* scaler
                self.ax2.set_ylabel(f'Mass balance for:\n {self.MB_title_string[:-2]}',color="purple")
                self.ax2.yaxis.set_label_position('right')
                self.ax2.plot(time_column,MB,label="Mass balance %",linestyle='-',marker='o',color="purple",markersize=6*self.DP_scaler)
                self.ax2.plot([0,max(time_column)],[100,100],linestyle='--',marker='',color='purple')
                if self.show_legend:
                    self.ax2.legend(loc='upper center')
                # Ensure appropriate y limits with space for legends. 
                self.ax2.set_ylim(0,max(MB)+28)
                if self.ylim!=None and type(self.ylim) in [float,int]:
                    self.ax.set_ylim(0,self.ylim)
                else:
                    self.ax.set_ylim(0,max(largest_conc)+max(largest_conc)*0.15)
                if self.show_legend:
                    if self.legend_outside:
                        # Calculate the width of the legend dynamically based on the longest text element
                        legend = self.ax.legend()
                        # Obtain the legend width in display units. 
                        legend_bbox = legend.get_window_extent()
                        legend_width = legend_bbox.width
                        # Obtain the figure width in display units.
                        fig_box = plt.gcf().get_window_extent()
                        fig_width_display = fig_box.width
                        # See by what fraction the figure width needs to be extended for outside legend.
                        legend_width_scaled=legend_width/fig_width_display
                        # Scale the right argument according to the legend width
                        self.fig.subplots_adjust(right=1 - legend_width_scaled-0.07)
                        # Place the legend outside the axis
                        legend_position_scaler=fig_size_scaler**0.3 if fig_size_scaler<1 else 1
                        self.ax.legend(loc='center left',bbox_to_anchor=(1.14/legend_position_scaler, 0.8))
                        # Adjust figure size to increase width according to the outside legend width
                        fig_size = plt.gcf().get_size_inches()
                        fig_size[0] = fig_size[0] + fig_size[0]*(legend_width_scaled) # Increase width by the padding
                        plt.gcf().set_size_inches(fig_size)
                    else:
                        self.ax.legend()
                plt.tight_layout()
                plt.draw()

            def on_key_press(self, event):
                if event.key == 'right':
                    self.next_plot()
                elif event.key == 'left':
                    self.previous_plot()

            def next_plot(self):
                self.current_plot_index = (self.current_plot_index + 1) % len(self.data)
                self.plot_current_data()

            def previous_plot(self):
                self.current_plot_index = (self.current_plot_index - 1) % len(self.data)
                self.plot_current_data()
        scrollable_plot = ScrollablePlot(data,species,stochiometry,fig_size_scaler=fig_size_scaler,
        t_unit=t_unit,y_unit=y_unit,scaler=scaler,ylim=ylim,legend_outside=legend_outside,linewidth=linewidth, DP_scaler=DP_scaler,show_legend=show_legend)
        plt.show()
    else: 
        # Create a twin plot for each experiment dataset
        for i in data.keys():
            Concs=[]
            largest_conc=[]
            fig,ax = plt.subplots(figsize=(7*0.8*fig_size_scaler, 5*0.8*fig_size_scaler))
            fig_size = plt.gcf().get_size_inches()
            time=data[i][t]
            # Create a curve for each reaction species
            for j in data[i].keys():
                if j==t:
                    continue
                ax.plot(time,data[i][j],label=j,linestyle='-',marker='o',
                        markersize=6*DP_scaler,linewidth=linewidth)
                ax.set_title(i)
                ax.set_xlabel(t)
                ax.set_ylabel(f'concentration / {y_unit}')
                largest_conc.append(max(data[i][j].to_numpy()))
                if t_unit!=None:
                    plt.xlabel(f'{t} / {t_unit}')
                if j==t or j not in species:
                    continue
                # Add concentration*stochiometry array for calculation of mass balance over time
                Concs.append(data[i][j].to_numpy()*stochiometry[species.index(j)])
            if show_legend:
                if legend_outside:
                    # Calculate the width of the legend dynamically based on the longest text element
                    legend = ax.legend()
                    # Obtain the legend width in display units. 
                    legend_bbox = legend.get_window_extent()
                    legend_width = legend_bbox.width
                    # Obtain the figure width in display units.
                    fig_box = plt.gcf().get_window_extent()
                    fig_width_display = fig_box.width
                    # See by what fraction the figure width needs to be extended for outside legend.
                    legend_width_scaled=legend_width/fig_width_display
                    # Scale the right argument according to the legend width
                    fig.subplots_adjust(right=1 - legend_width_scaled-0.07)
                    # Place the legend outside the axis
                    legend_position_scaler=fig_size_scaler**0.3 if fig_size_scaler<1 else 1
                    ax.legend(loc='center left',bbox_to_anchor=(1.14/legend_position_scaler, 0.8))
                    # Adjust figure size to increase width according to the outside legend width
                    fig_size = plt.gcf().get_size_inches()
                    fig_size[0] = fig_size[0] + fig_size[0]*(legend_width_scaled) # Increase width by the padding
                    plt.gcf().set_size_inches(fig_size)
                else:
                    ax.legend()
            # Use Concs to create mass balance sum for each time point
            sums=np.zeros(len(Concs[0]))
            for i in Concs:
                sums=sums+i
            # Use sums to find the % mass balance at each time relative to the intial value.
            MB=sums/sums[0]*100*scaler
            ax2=ax.twinx()
            ax2.set_ylabel(f'Mass balance for:\n {MB_title_string[:-2]}',color="purple")
            ax2.plot(time,MB,label="Mass balance %",linestyle='-',marker='o',color="purple",markersize=6*DP_scaler)
            ax2.plot([0,max(time)],[100,100],linestyle='--',marker='',color='purple')
            if show_legend:
                ax2.legend(loc='upper center')
            # Ensure appropriate y limits with space for legends. 
            ax2.set_ylim(0,max(MB)+28)
            if ylim!=None and type(ylim) in [float,int]:
                ax.set_ylim(0,ylim)
            else:
                ax.set_ylim(0,max(largest_conc)+max(largest_conc)*0.15)
            fig.subplots_adjust(right=0.85)
            plt.show()

