import numpy as np
import pandas as pd
from sklearn.metrics import pairwise_distances
from sklearn.preprocessing import RobustScaler


def _augment(row):
    max, avg, id = row.max(), row.mean(), row.index[0]
    return row.apply(lambda x: (x, max, avg, id))


def construct_portfolio(regret_matrix, meta_features, regret_bound):
    """The portfolio construction algorithm.

    Reference: [Mining Robust Default Configurations for Resource-constrained AutoML](https://arxiv.org/abs/2202.09927).

    Args:
        regret_matrix: A dataframe of regret matrix.
        meta_features: None or a dataframe of metafeatures matrix.
            When set to None, the algorithm uses greedy strategy.
            Otherwise, the algorithm uses greedy strategy with feedback
            from the nearest neighbor predictor.
        regret_bound: A float of the regret bound.

    Returns:
        A list of configuration names.
    """
    configs = []
    all_configs = set(regret_matrix.index.tolist())
    tasks = regret_matrix.columns
    # pre-processing
    if meta_features is not None:
        scaler = RobustScaler()
        meta_features = meta_features.loc[tasks]
        meta_features.loc[:, :] = scaler.fit_transform(meta_features)
        nearest_task = {}
        for t in tasks:
            other_meta_features = meta_features.drop(t)
            dist = pd.DataFrame(
                pairwise_distances(
                    meta_features.loc[t].to_numpy().reshape(1, -1),
                    other_meta_features,
                    metric="l2",
                ),
                columns=other_meta_features.index,
            )
            nearest_task[t] = dist.idxmin(axis=1)
        regret_matrix = regret_matrix.apply(_augment, axis=1)
        print(regret_matrix)

    def loss(configs):
        """Loss of config set `configs`, according to nearest neighbor config predictor."""
        if meta_features is not None:
            r = []
            best_config_per_task = regret_matrix.loc[configs, :].min()
            for t in tasks:
                config = best_config_per_task[nearest_task[t]].iloc[0][-1]
                r.append(regret_matrix[t][config][0])
        else:
            r = regret_matrix.loc[configs].min()
        excessive_regret = (np.array(r) - regret_bound).clip(min=0).sum()
        avg_regret = np.array(r).mean()
        return excessive_regret, avg_regret

    prev = np.inf
    i = 0
    eps = 1e-5
    while True:
        candidates = [configs + [d] for d in all_configs.difference(configs)]
        losses, avg_regret = tuple(zip(*(loss(x) for x in candidates)))
        sorted_losses = np.sort(losses)
        if sorted_losses[1] - sorted_losses[0] < eps:
            minloss = np.nanmin(losses)
            print(f"tie detected at loss = {sorted_losses[0]}, using alternative metric.")
            tied = np.flatnonzero(losses - minloss < eps)
            losses = [(avg_regret[i], i) for i in tied]
            minloss, ind = min(losses)
            if minloss > prev - eps:
                print(f"May be overfitting at k = {i + 1}, current = {minloss:.5f}, " f"prev = {prev:.5f}. Stopping.")
                break
            configs = candidates[ind]
            prev = minloss
        else:
            configs = candidates[np.nanargmin(losses)]
        i += 1
        if sorted_losses[0] <= eps:
            print(f"Reached target regret bound of {regret_bound}! k = {i}. Declining to pick further!")
            break

    return configs
