"""
Routines for converting between Pandas DataFrames and Parts objects.
"""

import pandas as pd
import numpy as np

def to_pandas(part):
    """Return the current Part object as a Pandas DataFrame
    
    Only applicable to Parts objects with 1 or 2 dimensions.
    """
    if part.ndim>2:
        raise ValueError(f"Cannot convert a Part with {part.ndim} dimensions to DataFrame.")
    elif part.ndim==2:
        return pd.DataFrame(part.data,
                            index = part.axes[0].label(True),
                            columns = part.axes[1].label(True))
    else:
        return pd.DataFrame(part.data,index = part.axes[0].label(True)
                                )
    
def make_part(df,name="from_df",
                label_detection=False,
                **kwargs):
    """Load a Part object from a Pandas DataFrame
    
    Parameters
    ----------
    df : DataFrame
        DataFrame to load
    label_detection : bool, optional
        Automatically detect labels, by default False
        If True, the DataFrame is scanned to detect labels (defined as non-numeric data)
    name : str, optional
        Name of the data variable to load, by default None.
        This can be left empty if there's a single variable in the DataFrame.
    
    Returns
    -------
    dict    
        Data required to create the Part object
    """
    part_data = dict()
    if label_detection:
        df = autodecode_labels(df)
    part_data["data"] = df.to_numpy()
    ndim = df.ndim

    labels = []
    if ndim == 1:
        labels.append(convert_labels(df.index))
    else:
        labels.append(convert_labels(df.index))
        labels.append(convert_labels(df.columns))
    labels = disambiguate_labels(labels)
    part_data["labels"] = labels
    part_data["groupings"] = kwargs.pop("groupings",dict())
    part_data["metadata"] = kwargs.pop("metadata",dict())
    part_data["name"] = name
    for key in kwargs:
        part_data["metadata"][key] = kwargs[key]
    return part_data

def autodecode_labels(df):
    """Automatically detect the labels from a DataFrame
    
    This is done by indentifying the indices and columns
    with non-numeric values.
    """
    def test_selection(df,row,col):
        """Test if a selection is numeric"""
        try:
            for col in df.iloc[row:,col]:
                pd.to_numeric(col)
            return True
        except ValueError:
            return False

    def try_reduce(df,row,col):
        """Try reducing the rectangle to the right or down"""
        if test_selection(df,row+1,col):
            return row+1,col
        elif test_selection(df,row,col+1):
            return row,col+1
        else:
            return row+1,col+1
        
    def try_expand(df,row,col):
        """Try expanding the rectangle to the left or up"""
        if not test_selection(df,row+1,col):
            return row+1,col
        elif not test_selection(df,row,col+1):
            return row,col+1
        else:
            return row, col

    def find_rectangle(df):
        """Find the largest rectangle with only numeric data"""
        row = 0
        col = 0
        while not test_selection(df,row,col):
            row,col = try_reduce(df,row,col)
        while not test_selection(df,row,col):
            #After the first while loop, we found only numeric data
            #We now expand to the top and the left
            #To make sure we didn't crop numerical data
            row,col = try_expand(df,row,col)
        return row,col
    
    #First, we find the largest rectangle with only numeric data
    row,col = find_rectangle(df)

    #And we remove potential nan axes and ensure types are ok
    data = pd.DataFrame(
        data=df.iloc[row:,col:],
        dtype=np.float64)

    #We count Nan axes as they offset label names
    row_offset = data.map(
        np.isnan
    ).all(1).sum()
    col_offset = data.map(
        np.isnan
    ).all(0).sum()

    
    data = data.dropna(axis=0,how="all")
    data = data.dropna(axis=1,how="all")


    #Then, we build the labels
    if col>0:
        col_names = df.iloc[:row,col-1+col_offset].to_list()
        if row > 1:
            labels = []
            sel = df.iloc[:row,col:].transpose()
            for column in sel.columns:
                labels.append(sel[column].dropna().unique())
            columns = pd.MultiIndex.from_product(
                labels,
                names = col_names)
        else:
            columns = pd.Index(
                df.iloc[
                    :row,col:
                    ].values.flatten(),
                name = col_names[0]
            )
        
    else:
        columns = None
    if row > 0:
        index_names = df.iloc[row-1+row_offset,:col].to_list()
        if col > 1:
            labels = []
            sel = df.iloc[row+row_offset:,:col]
            for column in sel.columns:
                labels.append(
                    list(sel[column].dropna().unique())
                )
            index = pd.MultiIndex.from_product(
                labels,
                names = index_names)
        else:
            index = pd.Index(
                list(
                    df.iloc[
                row:,:col
                ].values.flatten()
                ),
                name = index_names[0]
            )
    else:
        index = None

    #We build the formatted DataFrame
    output = pd.DataFrame(
        data = data.values,
        columns=columns,
        index = index
          )
    
    return output

def convert_labels(index):
    """Convert a Pandas Index to a dictionary of labels
    
    Parameters
    ----------
    index : Index
        Pandas Index to convert
    """
    output = []
    if isinstance(index,pd.MultiIndex):
        for i in range(index.nlevels):
            name = index.names[i]
            if name is None:
                name = f"level_{i}"
            output.append(
                {name : list(index.levels[i].values)}
            )
        return output
    if index.name is None:
        return [{0:list(index.array)}]
    return [{index.name:list(index.array)}]

def disambiguate_labels(labels):
    """Disambiguate the labels

    This allow solving labels ambiguity if the name was incorrectly loaded.
    
    Parameters
    ----------
    index : dict of str:list of str
        New index to disambiguate
    labels : list of str:list of str
        List of labels to disambiguate
    """
    ordered = []
    cleared = dict()
    flat_labels = [label_dim for label in labels for label_dim in label]
    values = []
    for label in labels:
        ordered.append([])
        for level in range(len(label)):
            name,value = list(
                label[level].keys()
            )[0],list(
                label[level].values()
            )[0]
            if name not in cleared.keys():
                if value in values:
                    #We have a duplicate
                    #We use the first occurrence as reference
                    ref_name = cleared.keys()[list(cleared.values()).index(value)]
                    ordered[-1].append(
                        {ref_name:value}
                    )
                    cleared[name] = value
            ordered[-1].append(label[level])
            cleared[name] = value
            values.append(value)

    return ordered