"""
Implements the base class for the tables.
"""
import os
from abc import ABCMeta, abstractmethod

import docutils.core
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from fitbenchmarking.utils.misc import get_js

FORMAT_DESCRIPTION = \
    {'abs': 'Absolute values are displayed in the table.',
     'rel': 'Relative values are displayed in the table.',
     'both': 'Absolute and relative values are displayed in '
             'the table in the format ``abs (rel)``'}
CONTRAST_RATIO_AAA = 7.0


class Table:
    """
    Base class for the FitBenchmarking HTML and text output tables.

    When inheriting from this, it may be useful to override the following
    functions as required:

    - get_value
    - display_str
    - get_error_str
    - get_link_str
    """
    __metaclass__ = ABCMeta
    name = None
    output_string_type = {"abs": '{:.4g}',
                          "rel": '{:.4g}',
                          "both": '{0:.4g} ({1:.4g})'}
    colour_template = 'background-color: {0}'
    cbar_title = "No colour bar description given"

    def __init__(self, results, best_results, options, group_dir,
                 pp_locations, table_name):
        """
        Initialise the class.

        :param results: Results grouped by row and category (for colouring)
        :type results:
            dict[str, dict[str, list[utils.fitbm_result.FittingResult]]]
        :param best_results: The best results from each row/category
        :type best_results:
            dict[str, dict[str, utils.fitbm_result.FittingResult]]
        :param options: Options used in fitting
        :type options: utils.options.Options
        :param group_dir: path to the directory where group results should be
                          stored
        :type group_dir: str
        :param pp_locations: the locations of the performance profiles
        :type pp_locations: dict[str,str]
        :param table_name: Name of the table
        :type table_name: str
        """
        # Flatten to reduce the necessity on having problems as rows.
        self.results = results
        self.best_results = best_results
        self.options = options
        self.group_dir = group_dir
        self.pp_locations = pp_locations
        self.table_name = table_name

        self.pp_location = ''
        self._table_title = None
        self._file_path = None
        self.pps = [self.name]

        self.cbar_left_label = "Best (1)"
        self.cbar_right_label = f"Worst (>{self.options.colour_ulim})"

        # Set up results as needed
        self.sorted_results = {}
        self.create_results_dict()
        self.problem_sizes = []
        self.create_prob_sizes_list()

    @abstractmethod
    def get_value(self, result):
        """
        Gets the main value to be reported in the tables for a given result

        If more than one value is returned please note that the first value
        will be used in the default colour handling.

        :param result: The result to generate the values for.
        :type result: FittingResult

        :return: The value to convert to a string for the tables
        :rtype: tuple(float)
        """
        raise NotImplementedError

    def display_str(self, value):
        """
        Converts a value generated by
        :meth:`~fitbenchmarking.results_processing.base_table.Table.get_value()`
        into a string respresentation to be used in the tables.
        Base class implementation takes
        the relative and absolute values and uses ``self.output_string_type``
        as a template for the string format. This can be overridden to
        adequately display the results.

        :param value: Relative and absolute values
        :type value: tuple

        :return: string representation of the value for display in the table.
        :rtype: str
        """
        rel_value, abs_value = value
        comp_mode = self.options.comparison_mode
        result_template = self.output_string_type[self.options.comparison_mode]
        if comp_mode == "abs":
            return result_template.format(abs_value)
        if comp_mode == "rel":
            return result_template.format(rel_value)
        # comp_mode == "both"
        return result_template.format(abs_value, rel_value)

    def get_link_str(self, result):
        """
        Get the link as a string for the result.
        This can be overridden if tables require different links.

        :param result: The result to get the link for
        :type result: FittingResult

        :return: The link to go to when the cell is selected
        :rtype: string
        """
        return os.path.relpath(path=result.fitting_report_link,
                               start=self.group_dir)

    @staticmethod
    def get_error_str(result, error_template='[{}]'):
        """
        Get the error string for a result based on error_template
        This can be overridden if tables require different error formatting.

        :param result: The result to get the error string for
        :type result: FittingResult

        :return: A string representation of the error
        :rtype: str
        """
        error_code = result.error_flag
        if error_code == 0:
            return ''

        return error_template.format(error_code)

    def create_results_dict(self):
        """
        Generate a dictionary of results lists with rows and columns as the key
        and list elements respectively.
        This is used to create HTML and csv tables.
        This is stored in self.sorted_results
        """
        self.sorted_results = {k: [r for cat in row.values() for r in cat]
                               for k, row in self.results.items()}

    def create_prob_sizes_list(self):
        """
        Generate a list of strings containing number of parameters and number
        of data points for each problem.
        This is stored in self.problem_sizes and is used to create the tables.
        """
        # Store info on problem size
        n_points_and_params = [(results[0].get_n_parameters(),
                                results[0].get_n_data_points())
                               for results in self.sorted_results.values()]

        # Build strings for problem size to display in table
        self.problem_sizes = [f'{m} params, {n} points'
                              for m, n in n_points_and_params]

    def get_str_dict(self, html=False):
        """
        Create a dictionary with the table values as strings for display.

        :return: The dictionary of strings for the table
        :rtype: dict[list[str]]
        """
        str_dict = {}
        for k, results in self.sorted_results.items():
            _, text_arrs = self.get_colours_for_row(results)
            str_dict[k] = [self.get_str_result(r, t, html)
                           for r, t in zip(results, text_arrs)]
        return str_dict

    def get_colour_df(self, like_df=None):
        """
        Generate a dataframe of colours to add to the html rendering.

        If like_df is passed this will use the column and row indexes of that
        dataframe.

        :param like_df: The dataframe to copy headings from. Defaults to None.
        :type like_df: pandas.DataFrame

        :return: A dataframe with colourings as strings
        :rtype: pandas.DataFrame
        """
        col_dict = {}
        for k, results in self.sorted_results.items():
            col_dict[k], _ = self.get_colours_for_row(results)

        table = pd.DataFrame.from_dict(col_dict, orient='index')

        if like_df is None:
            row = next(iter(self.sorted_results.values()))
            minimizers_list = [(r.software, r.minimizer) for r in row]
            table.columns = pd.MultiIndex.from_tuples(minimizers_list)
        else:
            table.columns = like_df.columns
            table.index = like_df.index
        return table

    def get_str_result(self, result, text_col=None, html=False):
        """
        Given a single result, generate the string to display in this table.
        The html flag can be used to switch between a plain text and html
        format.

        This is intended to be easily extensible by overriding the following
        functions:

        - get_value
        - display_str
        - get_error_str
        - get_link_str

        If you find yourself overriding this, please consider if changes could
        be made to allow future tables to benefit.

        :param result: The result to generate a string for
        :type result: fitbenchmarking.utils.ftibm_result.FittingResult
        :param text_col: Foreground colours for the text as html rgb strings
                         e.g. 'rgb(255, 255, 255)'
        :type text_col: list[str]
        :param html: Flag to control whether to generate a html string or plain
                     text. Defaults to False.
        :type html: bool

        :return: The string representation.
        :rtype: str
        """
        if html:
            val = self.get_value(result)
            val_str = self.display_str(val)
            error_str = self.get_error_str(result,
                                           error_template="<sup>{}</sup>")
            if 'inf' in val_str and self.table_name != 'local_min_table.':
                val_str = f'<span class="blank">Error {error_str}</span>'
            elif 'N/A' in val_str:
                val_str = '<span class="blank">N/A</span>'
            else:
                val_str = self.get_hyperlink(result,
                                             val_str + error_str,
                                             text_col)
        else:
            val_str = self.display_str(self.get_value(result))
            if val_str != 'N/A':
                val_str += self.get_error_str(result, error_template='[{}]')
        return val_str

    def get_hyperlink(self, result, val_str, text_col):
        """
        Generates the hyperlink for a given result

        :param result: The result to generate a string for
        :type result: fitbenchmarking.utils.ftibm_result.FittingResult
        :param val_str: Preprocessed val_str to display
        :type val_str: str
        :param text_col: Foreground colour for the text as html rgb strings
                         e.g. 'rgb(255, 255, 255)'
        :type text_col: str

        :return: The hyperlink representation.
        :rtype: str
        """
        color_to_class = {'rgb(0,0,0)': 'class="dark"',
                          'rgb(255,255,255)': 'class="light"'}
        val_str = (f'<a {color_to_class[text_col]} '
                   f'href="{self.get_link_str(result)}">'
                   f'{val_str}</a>')
        return val_str

    def get_colours_for_row(self, results):
        """
        Get the colours as strings for the given results in the table.
        The base class implementation, for example,
        uses the first value from self.get_value and
        ``colour_map``, ``colour_ulim`` and ``cmap_range`` within
        :class:`~fitbenchmarking.utils.options.Options`.

        :param result: Results to get the colours for.
        :type result: list[fitbenchmarking.utils.fitbm_result.FittingResult]

        :return: The colour to use for each cell in the list and
                 Foreground colours for the text as html rgb strings
                 e.g. 'rgb(255, 255, 255)'
        :rtype: tuple[list[str], list[str]]
        """
        values = [self.get_value(r)[0] for r in results]

        cmap_name = self.options.colour_map
        cmap = plt.get_cmap(cmap_name)
        cmap_ulim = self.options.colour_ulim
        cmap_range = self.options.cmap_range
        log_ulim = np.log10(cmap_ulim)  # colour map used with log spacing

        col_strs = ["background-colour: #ffffff" for _ in results]

        colours, text_str = self.vals_to_colour(values, cmap,
                                                cmap_range, log_ulim)
        for i, c in enumerate(colours):
            try:
                col_strs[i] = self.colour_template.format(c)
            except IndexError:
                col_strs[i] = self.colour_template.format(*c)

        return col_strs, text_str

    def create_pandas_data_frame(self, html=False):
        """
        Creates a pandas data frame of results

        :param html: Whether to make the dataframe for html or plain text
        :type html: bool. defaults to False

        :return: DataFrame with string representations of results
        :rtype: pandas.DataFrame
        """
        str_results = self.get_str_dict(html)
        # Check all rows incase first row has missing information
        minimizers_list = []
        for row in self.sorted_results.values():
            for i, r in enumerate(row):
                formatted = (r.software, r.modified_minimizer_name(False))
                if len(minimizers_list) == i:
                    minimizers_list.append(formatted)
                elif len(minimizers_list[i][1]) < len(formatted[1]):
                    minimizers_list[i] = formatted

        multi_columns = pd.MultiIndex.from_tuples(minimizers_list)

        single_index = list(str_results.keys())

        multi_index = pd.MultiIndex.from_tuples(zip(single_index,
                                                    self.problem_sizes))

        results_only = np.array(list(str_results.values()))

        table = pd.DataFrame(results_only,
                             index=multi_index,
                             columns=multi_columns)

        return table

    def to_html(self):
        """
        Generate a html version of the table.

        :return: HTML table output
        :rtype: str
        """
        table = self.create_pandas_data_frame(html=True)

        # Format the table headers
        cost_func_template = '<a class="cost_function_header" ' \
                             'href=https://fitbenchmarking.readthedocs.io/' \
                             'en/latest/users/options/fitting_option.html' \
                             '#cost-function-cost-func-type ' \
                             'target="_blank">{0}</a>'
        software_template = '<a class="software_header" ' \
                            'href="https://fitbenchmarking.readthedocs.io/' \
                            'en/latest/users/options/minimizer_option.html' \
                            '#{0}" target="_blank">{0}</a>'
        minimizer_template = '<a class="minimizer_header" col={0} ' \
                             'title="{1}"' \
                             'href="https://fitbenchmarking.readthedocs.io/' \
                             'en/latest/users/options/minimizer_option.html' \
                             '#{2}" target="_blank">{3}</a>'

        minimizers_list = []
        for row in self.sorted_results.values():
            for i, result in enumerate(row):
                formatted = (
                    cost_func_template.format(result.costfun_tag),
                    software_template.format(
                        result.software.replace('_', '-')),
                    minimizer_template.format(
                        i,
                        result.algorithm_type,
                        result.software.replace('_', '-'),
                        result.modified_minimizer_name()))
                if len(minimizers_list) == i:
                    minimizers_list.append(formatted)
                elif len(minimizers_list[i][2]) < len(formatted[2]):
                    minimizers_list[i] = formatted

        columns = pd.MultiIndex.from_tuples(minimizers_list)
        table.columns = columns

        # Format the row labels
        double_index = []
        for b, i1, i2 in zip(self.best_results.values(),
                             self.best_results.keys(),
                             self.problem_sizes):

            b = next(iter(b.values()))
            rel_path = os.path.relpath(
                path=b.problem_summary_page_link,
                start=self.group_dir)

            double_index.append((f'<a class="problem_header" '
                                 f'href="{rel_path}">{i1}</a>',
                                 f'<a class="problem_header_lev1" '
                                 f'href="{rel_path}">{i2}</a>'))

        multi_index = pd.MultiIndex.from_tuples(double_index)
        table.index = multi_index

        # Get columns where cost function changes
        column_dividers = [table.columns[0]]
        for column in table.columns[1:]:
            if column[0] != column_dividers[-1][0]:
                column_dividers.append(column)
        column_dividers = column_dividers[1:]

        # Set the cell colours and increase bars between cost functions
        table_style = table.style\
            .apply(lambda df: self.get_colour_df(like_df=df), axis=None)\
            .set_table_styles(table_styles={
                k: [{'selector': 'td',
                     'props': [('border-left-width', '3px')]},
                    {'selector': 'th',
                     'props': [('border-left-width', '3px')]}]
                for k in column_dividers})

        return table_style.to_html(table_uuid='table')

    def to_csv_file(self):
        """
        Generate a plain text version of the table

        :return: Plain text table output
        :rtype: str
        """
        table = self.create_pandas_data_frame(html=False)
        return table.to_csv()

    def get_description(self):
        """
        Generates table description from class docstrings and converts them
        into html

        :return: Dictionary containing table descriptions
        :rtype: dict
        """
        FORMAT_DESCRIPTION[self.name] = self.__doc__

        html = {}
        for name in [self.name, self.options.comparison_mode]:
            descrip = FORMAT_DESCRIPTION[name]
            descrip = descrip.replace(':ref:', '')
            js = get_js(self.options, self.group_dir)
            docsettings = {
                'math_output': 'MathJax '+js['mathjax']
            }
            description_page = docutils.core.publish_parts(
                descrip,
                writer_name='html',
                settings_overrides=docsettings)
            html[name] = description_page['body'].replace('<blockquote>\n', '')
        return html

    @property
    def table_title(self):
        """
        Getter function for table name if self._table_title is None

        :return: name of table
        :rtype: str
        """
        if self._table_title is None:
            self._table_title = f"FitBenchmarking: {self.name} table"
        return self._table_title

    @table_title.setter
    def table_title(self, value):
        """
        Setting function to set the name of the table

        :param value: name of table
        :type value: str
        """
        self._table_title = value

    @property
    def file_path(self):
        """
        Getter function for the path to the table

        :return: path to table
        :rtype: str
        """
        if self._file_path is None:
            self._file_path = os.path.join(self.group_dir, self.table_name)
        return self._file_path

    @file_path.setter
    def file_path(self, value):
        """
        Setting function to set the path to the table

        :param value: path to table
        :type value: str
        """
        self._file_path = value

    @staticmethod
    def vals_to_colour(vals, cmap, cmap_range, log_ulim):
        """
        Converts an array of values to a list of hexadecimal colour
        strings using logarithmic sampling from a matplotlib colourmap
        according to relative value.

        :param vals: values in the range [0, 1] to convert to colour strings
        :type vals: list[float]
        :param cmap: matplotlib colourmap
        :type cmap: matplotlib colourmap object
        :param cmap_range: values in range [0, 1] for colourmap cropping
        :type cmap_range: list[float], 2 elements
        :param log_ulim: log10 of worst shading cutoff value
        :type log_ulim: float

        :return: Colours as hex strings for each input value and
                 Foreground colours for the text as html rgb strings
                 e.g. 'rgb(255, 255, 255)'
        :rtype: tuple[list[str], list[str]]
        """
        log_vals = np.log10(vals)
        log_llim = min(log_vals)
        if np.isinf(log_llim):
            norm_vals = np.repeat(np.nan, len(vals))
        else:
            norm_vals = (log_vals - log_llim) \
             / (log_ulim - log_llim)
        norm_vals[norm_vals > 1] = 1  # applying upper cutoff
        # trimming colour map according to default/user input
        norm_vals = cmap_range[0] + \
            norm_vals*(cmap_range[1] - cmap_range[0])
        rgba = cmap(norm_vals)
        hex_strs = [mpl.colors.to_hex("whitesmoke") if np.isinf(v) else
                    mpl.colors.rgb2hex(colour)
                    for colour, v in zip(rgba, vals)]
        text_str = [background_to_text(colour[:3], CONTRAST_RATIO_AAA)
                    for colour in rgba]

        return hex_strs, text_str

    def save_colourbar(self, fig_dir, n_divs=100, sz_in=(3, 0.8)) -> str:
        """
        Generates a png of a labelled colourbar using matplotlib.

        :param fig_dir: path to figures directory
        :type fig_dir: str
        :param n_divs: number of divisions of shading in colourbar
        :type n_divs: int
        :param sz_in: dimensions of png in inches [width, height]
        :type sz_in: list[float] - 2 elements

        :return: The relative path to the colourbar image.
        :rtype: str
        """
        fig_path = os.path.join(fig_dir, f"{self.name}_cbar.png")

        figh = 0.77
        fig, ax = plt.subplots(nrows=1, figsize=(6.4, figh))
        fig.subplots_adjust(top=1 - 0.35 / figh, bottom=0.15 / figh,
                            left=0.3, right=0.7, hspace=1)

        cmap_range = self.options.cmap_range
        gradient = np.linspace(cmap_range[0], cmap_range[1], n_divs)
        gradient = np.vstack((gradient, gradient))

        ax.imshow(gradient, aspect='auto',
                  cmap=plt.get_cmap(self.options.colour_map), vmin=0, vmax=1)

        ax.text(-0.02, 0.5, self.cbar_left_label,
                va='center', ha='right', fontsize=6,
                transform=ax.transAxes)
        ax.text(1.02, 0.5, self.cbar_right_label,
                va='center', ha='left', fontsize=6,
                transform=ax.transAxes)
        ax.set_title(self.cbar_title, fontsize=6)
        ax.set_axis_off()
        fig.set_size_inches(sz_in[0], sz_in[1])

        plt.savefig(fig_path, dpi=150)
        plt.close(fig)

        return os.path.relpath(fig_path, self.group_dir)

    def problem_dropdown_html(self) -> str:
        """
        Generates the HTML for a dropdown checklist of problem sets.

        :return: HTML for a dropdown checklist of problem sets.
        :rtype: str
        """
        items = [f'        <li><label class="noselect"><input '
                 f'type="checkbox" checked=true '
                 f'onclick="toggle_problem(\'{problem_name}\')"/> '
                 f'{problem_name}</label></li>'
                 for problem_name in self.sorted_results.keys()]

        return self._dropdown_html("problem_dropdown", "Select Problems",
                                   items)

    def minimizer_dropdown_html(self) -> str:
        """
        Generates the HTML for a dropdown checklist of minimizers.

        :return: HTML for a dropdown checklist of minimizers.
        :rtype: str
        """
        minimizers = []
        for row in self.sorted_results.values():
            for i, r in enumerate(row):
                formatted = (r.software.replace('_', '-'),
                             r.modified_minimizer_name())
                if len(minimizers) == i:
                    minimizers.append(formatted)
                elif len(minimizers[i][1]) < len(formatted[1]):
                    minimizers[i] = formatted

        # Remove duplicates
        minimizers = list(dict.fromkeys(minimizers))

        items = [f'        <li><label class="noselect"><input '
                 f'type="checkbox" checked=true '
                 f'onclick="toggle_minimizer(\'{software}\', '
                 f'\'{minimizer}\')"/> {minimizer}</label></li>'
                 for software, minimizer in minimizers]

        return self._dropdown_html("minimizer_dropdown",
                                   "Select Minimizers", items)

    @staticmethod
    def _dropdown_html(list_id: str, selector_text: str,
                       checklist: list) -> str:
        """
        Generates the HTML for a dropdown checklist. The list of items
        must be provided to this function.

        :param list_id: The ID to give the dropdown button.
        :type list_id: str
        :param selector_text: The text to display on the dropdown button.
        :type selector_text: str
        :param checklist: A list of HTML checkboxes to include in the
        dropdown.
        :type checklist: list
        :return: HTML for a dropdown checklist.
        :rtype: str
        """
        checklist_str = "\n".join(checklist)
        html = f'<div id="{list_id}" class="dropdown-check-list" ' \
               f'tabindex="100">\n' \
               f'    <span class="anchor" onclick="show_dropdown' \
               f'(\'{list_id}\')">{selector_text}</span>\n' \
               '    <ul class="items">\n' \
               f'{checklist_str}\n' \
               '    </ul>\n' \
               '</div>'
        return html

    @staticmethod
    def probsize_checkbox_html() -> str:
        """
        HTML for a checkbox, to allow toggling the problem size header.

        :return: HTML for a checkbox.
        :rtype: str
        """

        html = '<label class="checkbox_container">'\
               '<input type="checkbox" id="checkbox_prob_size" '\
               'onclick="toggle_prob_size_header()" '\
               'checked="checked"> Problem size column'\
               '</label>'
        return html


def background_to_text(background_col, contrast_threshold):
    """
    Determines the foreground color for the table elements. The optimum
    color is selected from two options - white and black. White is selected
    if its contrast ratio is greater than the contrast threshold. However,
    If the contrast ratio of white text does not meet the requirement, then
    the text color which provides the greatest contrast ratio is selected.

    :param background_col: a list of r,g,b values [0, 255]
    :type vals: list[int]
    :param contrast_threshold: the threshold value [0, 21]
    :type contrast_threshold: float

    :return: Foreground colour for the text as html rgb strings
             e.g. 'rgb(255, 255, 255)'
    :rtype: str
    """
    w = calculate_contrast(background_col, [1, 1, 1])  # white (default)
    b = calculate_contrast(background_col, [0, 0, 0])  # black
    contrast = {'rgb(255,255,255)': w, 'rgb(0,0,0)': b}
    if w <= contrast_threshold:
        text_str = max(contrast, key=contrast.get)
    else:
        text_str = 'rgb(255,255,255)'
    return text_str


def calculate_contrast(background, foreground):
    """
    Calculates the contrast ratio between the background and foreground
    colors. Visit link for more info:
    https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-contrast-ratio

    :param background: list of r, g, b values representing background [0, 255]
    :type vals: list[int]
    :param foreground: list of r, g, b values representing foreground [0, 255]
    :type foreground: list[int]

    :return: the contrast ratio [0, 21]
    :rtype: float
    """
    back_lum = calculate_luminance(background)
    fore_lum = calculate_luminance(foreground)
    brightest = max(back_lum, fore_lum)
    darkest = min(back_lum, fore_lum)
    return (brightest+0.05)/(darkest+0.05)


def calculate_luminance(rgb):
    """
    Calculates the relative luminance. Visit link for more info:
    https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-relative-luminance

    :param rgb: a list containing r, g, b values [0, 255]
    :type vals: list[int]

    :return: the luminance [0, 1]
    :rtype: float
    """
    a = list(map(lambda color: color/12.92
             if color <= 0.03928
             else (((color+0.055)/1.055)**2.4), rgb))
    return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722
